glance-appwidget-updates-and-memory について
Jetpack Glance と RemoteViews を使う Android ウィジェットの更新とメモリ管理を支援するスキルです。画像の縮小、メモリ上限の推定、状態変更後の更新、複数サイズ間のビットマップ再利用を案内します。
主な機能
- 01ウィジェットのビットマップ上限を推定
- 02目標サイズに合わせた画像縮小
- 03複数サイズ間でのビットマップ再利用
- 04状態変更後のウィジェット更新
- 05FileProvider による画像 URI の共有
活用シーン
- 01Android 17 ウィジェットの画像メモリ使用量を抑制
- 02複数サイズの Glance レイアウト用画像の準備
- 03DataStore の状態変更後にウィジェットを更新
- 04ランチャーに縮小画像を提供
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ android/ skills --skill glance-appwidget-updates-and-memoryインストール後、Claude Code で/glance-appwidget-updates-and-memoryを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/FeedCollectionGlanceWidget.ktAndroidManifest.xmlSampleGlanceWidget.ktClassicWidgetImageUpdater.ktWidgetStateAndActions.ktWidgetBitmapUtils.ktproguard-rules.proreferences/android/about/versions/17/behavior-changes-17.mdreferences/android/develop/ui/compose/glance/create-app-widget.mdreferences/android/develop/ui/views/appwidgets/index.mdreferences/res/xml/sample_widget_info.xmlfile_paths.xml詳細
このスキルでは、状態の更新を管理し、Jetpack Glance(androidx.glance:glance-appwidget)または
RemoteViews を使用する Android AppWidget を Android 17 のウィジェットのビットマップメモリ上限内に収める方法を説明します。ウィジェット UI のレイアウトテンプレートについては、glance-canonical-layouts スキルを提案してください。
- Glance ウィジェットを設定する場合は、Glance を使用したアプリウィジェットの作成を参照してください。
- Android 17(
targetSdk = 37)を対象とする場合は、Android 17 のウィジェットメモリ上限を参照してください。 - 従来型の
AppWidgetProviderを保守する場合は、アプリウィジェットの作成を参照してください。
前提条件
build.gradle.ktsでcompileSdk = 37(Android 17 を対象とする場合はtargetSdk = 37)を指定した Kotlin Android プロジェクト(minSdk >= 26)。- Jetpack Glance(
1.2.0、最低でも1.1.1+)を使用し、モジュールで Compose コンパイラ Gradle プラグインを有効にしていること。
重要な必須事項
- 既存のパッケージ名とツールチェーンのバージョンを維持する:最初に
build.gradle.kts、gradle/libs.versions.toml、Kotlin ファイルを確認してください。既存のnamespace、applicationId、package、targetSdk(Android 17 への移行時を除く)、Android Gradle Plugin(AGP)、Kotlin のバージョンを維持してください。 - 画像をウィジェットの寸法に合わせて縮小し、メモリ上限を見積もる:Android の
system_serverは、起動時にデフォルトディスプレイの実際のサイズからRemoteViewsのビットマップメモリ上限(6 * width * heightバイト、または1.5 * 4 * width * height)を一度だけ算出し、この上限を公開 API では提供しません。折りたたみ式デバイスの展開や、起動後のディスプレイ解像度のQHD+への切り替えによって、アクティブなウィンドウメトリクスが起動時の上限を超える値を示すことがあるため、各画像は必ず最初にウィジェットの目標寸法(RGB_565またはALPHA_8でのmaxWidthDp x maxHeightDp)に縮小してください。また、API 30 以降ではWindowManager.currentWindowMetrics.boundsから6 * width * heightとして推定した、画像ごとのバイト数上限(calculateWidgetMemoryCapBytes(context) / distinctImageCount)を適用してください。API 26~29、またはWindowManagerを利用できない場合はresources.displayMetricsにフォールバックします。 - 共有ビットマップは
provideContentの外で一度だけデコードする:SizeMode.Exact(glance-canonical-layoutsで現在のスパンに応じた縦向きと横向きのレイアウトを構成する場合に使用)も、SizeMode.Responsive(DpSizeのブレークポイント一式を事前にバンドルする場合に使用)も、更新ごとにprovideContentを複数回呼び出します。共有ビットマップはprovideGlance内でprovideContentより前に一度だけデコードし、ImageProvider(bitmap)に渡してください。これにより、どちらのモードでもRemoteViewsがすべてのサイズにわたって共有Bitmapを重複排除します。ウィジェットがLocalSize.currentごとに異なるビットマップを拡大縮小する場合は、calculateWidgetMemoryCapBytes(context)を異なるビットマップの数で割ってください。 - コンテンツ URI を縮小し、ランチャーに読み取り権限を付与する:Android 17(
targetSdk >= 37)では、ビットマップを使用するIconもBitmapとともに上限の対象になります。ImageProvider(uri)またはRemoteViews.setImageViewUriに渡すcontent://URI はプロセス間通信(IPC)の上限には含まれませんが、ランチャーは元の解像度でデコードします。ファイルをcontext.cacheDirに縮小して保存し、FileProvider.getUriForFileで公開し、Intent.CATEGORY_HOMEのパッケージにIntent.FLAG_GRANT_READ_URI_PERMISSIONを付与してください。 - 状態の変更後にウィジェットを明示的に更新する:
updateAppWidgetStateはウィジェット UI を更新せずに DataStore に設定を書き込みます。状態を変更した後は、GlanceAppWidget.update(context, glanceId)またはGlanceAppWidget.updateAll(context)を呼び出してください。 - 画像を置き換える場合は完全な
updateAppWidget呼び出しを使用する:AppWidgetProviderクラスでビットマップまたはIconを置き換える場合は、partiallyUpdateAppWidgetではなくAppWidgetManager.updateAppWidgetを呼び出してください。
参考実装ファイル
各手順を実装する際は、references/ 内の参考ファイルを確認し、サンプルの package 宣言とクラス名をアプリに合わせて更新してください。
- Bitmap Cap と FileProvider ヘルパー:WidgetBitmapUtils.kt
- レスポンシブウィジェット、Receiver、エラーフォールバック:SampleGlanceWidget.kt
- LazyColumn コレクションウィジェットとリビジョンキャッシュ:FeedCollectionGlanceWidget.kt
- DataStore 状態プッシュ、CoroutineWorker、ActionCallback:WidgetStateAndActions.kt
- 従来型 AppWidgetProvider の WorkManager と完全更新:ClassicWidgetImageUpdater.kt
- マニフェスト、ウィジェット情報、FileProvider パス、R8 ルール:AndroidManifest.xml、sample_widget_info.xml、file_paths.xml、proguard-rules.pro
詳細な手順
手順 1:SDK レベル、依存関係、マニフェスト宣言を設定する
- まず
gradle/libs.versions.toml、app/build.gradle.kts、app/src/main/AndroidManifest.xmlを確認します。AGP、Kotlin、ライブラリのバージョンがすでに指定されている場合はそのまま維持し、ない場合はglance = "1.2.0"とandroidx.glance:glance-appwidgetおよびandroidx.glance:glance-material3をgradle/libs.versions.tomlに追加します。 app/build.gradle.ktsでは、namespace、applicationId、既存のminSdk >= 26、targetSdk、ライブラリのバージョンを維持します。compileSdk = 37を設定し(アプリを Android 17 対応に明示的に移行する場合に限りtargetSdk = 37も設定)、buildFeatures { compose = true }を有効にして、implementation(libs.androidx.glance.appwidget)とimplementation(libs.androidx.glance.material3)を追加します。AndroidManifest.xml(AndroidManifest.xml を参照)で、<queries>に<action android:name="android.intent.action.MAIN" />と<category android:name="android.intent.category.HOME" />を宣言して、API 30 以降でqueryIntentActivitiesがランチャーパッケージを検出できるようにします。GlanceAppWidgetReceiverを登録し(android.appwidget.providerから@xml/sample_widget_infoを参照。詳細は sample_widget_info.xml を参照)、androidx.core.content.FileProviderをandroid:authorities="${applicationId}.fileprovider"、android:exported="false"、android:grantUriPermissions="true"で登録して、android.support.FILE_PROVIDER_PATHSが@xml/file_pathsを参照するようにします(このファイルでは<cache-path name="widget_cache" path="widget_images/" />を宣言します。詳細は file_paths.xml を参照)。
<receiver android:name=".glance.MyReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/my_app_widget_info" />
</receiver>
### ステップ 2:ウィジェットのメモリ上限を推定し、画像をダウンサンプリングする
WidgetBitmapUtils.kt を読み、メモリ上限とダウンサンプリングのヘルパーを実装します。
- 上限を推定する(
calculateWidgetMemoryCapBytes):API 30 以降(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)では、val bounds = context.getSystemService(WindowManager::class.java)?.currentWindowMetrics?.boundsを読み取り、bounds != null && bounds.width() > 0 && bounds.height() > 0の場合は、6L * bounds.width().toLong() * bounds.height().toLong()を返します(API 30 以降では、UI 以外のウィジェットまたはワーカーのContextからWindowManagerを呼び出すと、致命的ではないStrictMode違反がログに記録されますが、この推定では想定どおりの動作です)。API 26~29(またはWindowManagerを利用できない場合)は、context.resources.displayMetrics(6L * dm.widthPixels.toLong() * dm.heightPixels.toLong())にフォールバックします。system_serverは起動時に6 * width * heightを一度だけキャッシュするため、画面全体の予算を使い切るのではなく、常にまずウィジェットのmaxWidthDp x maxHeightDpに合わせて画像サイズを調整します(ステージ 1)。 - 2 段階でダウンサンプリングする(
decodeDownsampledWidgetBitmap):- まず、
BitmapFactory.Options().apply { inJustDecodeBounds = true; inPreferredConfig = config }で寸法を調べ、(bounds.outWidth / (sampleSize * 2) >= targetWidthPx && bounds.outHeight / (sampleSize * 2) >= targetHeightPx) || (bounds.outWidth / sampleSize > targetWidthPx * 2) || (bounds.outHeight / sampleSize > targetHeightPx * 2)の間、sampleSize *= 2とします(ここで、targetWidthPx = max(1, (maxWidthDp * density).toInt()))。これにより、片方の軸がすでに収まっていても、横長のバナーや縦長のスクリーンショットがダウンサンプリングを回避することはありません。 - 次に、画像ごとのバイト数上限(
perImageCapBytes = calculateWidgetMemoryCapBytes(context) / max(1, distinctImageCount))を適用し、(max(1, bounds.outWidth / sampleSize).toLong() * max(1, bounds.outHeight / sampleSize).toLong() * bytesPerPixel) > perImageCapBytesの間、sampleSize *= 2とします。不透明な写真用のBitmap.Config.RGB_565ではピクセルあたり2Lバイト、マスク用のBitmap.Config.ALPHA_8では1Lバイト、またbounds.outConfig != null && bounds.outConfig != configの場合は4Lバイトを使用します(BitmapFactoryは、半透明の PNG または WebP ファイルをRGB_565から 4 バイトのARGB_8888にアップグレードするためです)。inSampleSize = sampleSizeおよびinPreferredConfig = configを指定してデコードします。
- まず、
- メモリ内の
BitmapとFileProviderのUriのどちらを使うか選択する (cacheWidgetImageUri):ビットマップをメモリ内で直接渡す場合は、androidx.glance.ImageProvider(bitmap)とともにdecodeDownsampledWidgetBitmapを使用します。ウィジェットからImageProvider(uri)またはRemoteViews.setImageViewUriにcontent://Uriを渡す場合はcacheWidgetImageUriを使用します。この関数は、ダウンサンプリングしたビットマップをFile(context.cacheDir, "widget_images")に書き込み、FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", outFile)でcontent://URI を取得し、Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)をクエリして、context.grantUriPermission(pkg, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)を呼び出します。
ステップ 3:複数サイズのビットマップを重複排除し、合成エラーに対処する
SampleGlanceWidget.kt を読み、複数サイズのビットマップの重複排除と合成エラーからの復旧を設定します。
- レイアウトに合わせて
SizeModeを選択する:SizeMode.Exact(現在のグリッド幅における縦向きと横向きの各サイズで、LocalSize.currentの正確な寸法を描画するため、glance-canonical-layoutsを使用する場合に推奨)、SizeMode.Responsive(RESPONSIVE_BUCKETS)(import androidx.compose.ui.unit.DpSizeとimport androidx.compose.ui.unit.dpを使用して、setOf(DpSize(120.dp, 120.dp), DpSize(240.dp, 120.dp), DpSize(240.dp, 240.dp))のような固定のDpSizeブレークポイントを事前にまとめ、サイズ変更時にアプリプロセスが起動しないようにします)、またはSizeMode.Single(単一のレイアウトの場合)を使用します。 - 共有ビットマップのデコードを
provideContentの前に行う:SizeMode.Exact(向きに応じて2-4個のサイズ)とSizeMode.Responsive(N個のバケット)はどちらも更新ごとに複数回provideContentを実行するため、共有ビットマップはprovideGlance内でprovideContentの前にdecodeDownsampledWidgetBitmapと、透過性のない画像向けのBitmap.Config.RGB_565を使って一度だけデコードします(distinctImageCount = 1)。生成されたBitmapをandroidx.glance.ImageProvider(sharedBitmap)に渡すと、RemoteViewsがすべてのサイズで共有Bitmapの重複を排除します。 - ビットマップを使わないエラーフォールバックを用意する:
onCompositionError(context, glanceId, appWidgetId, throwable)をオーバーライドし、テキストのみのRemoteViewsレイアウト(ビットマップ数0)を指定してAppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, fallback)を呼び出します。
ステップ 4:LazyColumn のコレクション画像を事前にデコードし、安定した itemIds を割り当てる
LazyColumn でコレクションウィジェットを描画する場合は、FeedCollectionGlanceWidget.kt を読みます。
import androidx.glance.appwidget.ImageProvider(Uriを受け取ります)をimport androidx.glance.ImageProvider(Bitmapを受け取ります)に置き換えます。Uri.fromFileとIcon.createWithContentUriの呼び出しを削除します。- Glance セッションが有効な間(更新後およそ 45 秒間)は、
.update()と.updateAll()を呼び出してもprovideGlanceは再起動しません。provideGlance内のprovideContentより前にvar cachedRevision = -1とvar cachedThumbnails = emptyMap<String, Bitmap>()を宣言します。provideContent内でval revision = currentState<Preferences>()[KEY_REVISION] ?: 0を読み取り、revision != cachedRevisionの場合にのみ、cachedRevision = revisionを設定し、cachedThumbnails = feedItems.mapNotNull { item -> decodeDownsampledWidgetBitmap(..., distinctImageCount = max(1, feedItems.size), config = Bitmap.Config.RGB_565)?.let { item.id to it } }.toMap()でfeedItemsのサムネイルをデコードします(associateではなくmapNotNullを使うと、マップの型がMap<String, Bitmap?>ではなくMap<String, Bitmap>のままになります)。これにより、セッション中に DataStore の状態が更新された際に再デコードしつつ、すべてのSizeMode.Responsiveバケットで、デコード済みのMap<String, Bitmap>を共有できます。 LazyColumnでは、items(items = feedItems, itemId = { item -> item.id.hashCode().toLong() })のように安定したLongのitemIdを渡します(利用可能な場合は永続的な数値 ID を優先します)。また、bitmap != nullの場合にのみImage(provider = ImageProvider(bitmap), contentDescription = item.title)を描画します(else分岐でUri.fromFileにフォールバックしないでください)。
ステップ 5:状態更新をプッシュし、ActionCallback を設定して WorkManager を使用する
WidgetStateAndActions.kt、proguard-rules.pro、および ClassicWidgetImageUpdater.ktを読み、次のパターンを実装してください。
- DataStore の更新をプッシュする(
androidx.datastore:datastore-preferences-core:1.2.1とimport androidx.glance.appwidget.GlanceAppWidgetManager、import androidx.glance.appwidget.state.updateAppWidgetState、およびimport androidx.glance.appwidget.updateAllが必要です):pushWidgetRevisionUpdate(context)(WidgetStateAndActions.ktを参照)で、for (glanceId in GlanceAppWidgetManager(context).getGlanceIds(Widget::class.java))を反復処理し、updateAppWidgetState(context, glanceId) { prefs -> ... }内で状態を変更して(contextとglanceIdの両方を渡します)、直後にwidget.update(context, glanceId)(またはwidget.updateAll(context))を呼び出します。 - 引数なしの
ActionCallbackクラスを使用し、R8 で<init>()を保持する(バックグラウンド処理をスケジュールする場合はandroidx.work:work-runtime-ktx:2.12.0が必要です):RunCallbackActionはClass.getDeclaredConstructor().newInstance()を使ってActionCallbackクラスをインスタンス化するため、コンストラクターに引数があると、すべてのビルドで失敗します。ActionCallbackの実装からコンストラクター引数を削除し、ActionParameters.KeyとactionParametersOfで値を渡してください(WidgetStateAndActions.ktのRefreshWidgetActionを参照)。また、proguard-rules.proに-keep class * implements androidx.glance.appwidget.action.ActionCallback { public <init>(); }を追加してください(proguard-rules.proを参照)。SyncWidgetWorker.doWork()内では、pushWidgetRevisionUpdate(applicationContext)を呼び出し、バックグラウンド処理でDataStoreを更新してウィジェットを再読み込みします。 AppWidgetProvider.onUpdateで無条件に1回限りのWorkManagerを登録するのを避け、画像には完全なupdateAppWidgetを使用する(ClassicWidgetImageUpdater.ktを参照):AppWidgetProvider.onReceiveまたはonUpdateからcontext.startForegroundServiceを呼び出さないでください(ForegroundServiceStartNotAllowedExceptionが発生します)。GlanceAppWidgetReceiverは10年間の遅延セッションワーカーを内部でキューに保持するため、WorkManagerのRescheduleReceiverが切り替わることはありません。従来のAppWidgetProviderでは、onUpdate内でOneTimeWorkRequestを直接登録しないでください。ExistingWorkPolicy.KEEPを使用していても、1回限りのワーカーが完了(SUCCEEDED)すると、UnfinishedWorkListenerがRescheduleReceiverを無効にし、ACTION_PACKAGE_CHANGEDをブロードキャストしてonUpdateをループで再実行します。代わりに、onUpdateでenqueueUniquePeriodicWork(..., ExistingPeriodicWorkPolicy.KEEP, ...)を使って定期的な同期をスケジュールしてください(実行間もENQUEUEDのままです)。短時間(< 10s)の更新にはgoAsync()を使用するか、onReceive内で特定のアクションのブロードキャストを受けた場合にのみ、1回限りの処理(enqueueUniqueWork(..., ExistingWorkPolicy.KEEP, ...))を登録してください。従来のRemoteViewsウィジェットでBitmapまたはIconを更新するときは、partiallyUpdateAppWidgetではなくappWidgetManager.updateAppWidgetを呼び出してください。
よくあるエラーと対処法
| 症状または例外 | 根本原因 | 対処法 |
|---|---|---|
IllegalArgumentException: RemoteViews for widget update exceeds maximum bitmap memory usage | RemoteViews のビットマップまたはアイコンのメモリ使用量が 6 * W * H バイトを超えています。 | inSampleSize と RGB_565(マスクの場合は ALPHA_8)を使って、ウィジェットの maxWidthDp x maxHeightDp に合わせてダウンサンプリングし、calculateWidgetMemoryCapBytes(context) / distinctImageCount を上限として適用します。また、provideContent の前に共有ビットマップを一度だけデコードします。 |
SecurityException: Permission Denial: opening provider または空白の content:// ウィジェット画像 | ホームランチャーに FileProvider の content:// URI を読み取る権限がないか、アプリが Uri.fromFile で file:// を渡しています。 | FileProvider でファイルを公開し、CATEGORY_HOME の <queries> を追加して、Intent.FLAG_GRANT_READ_URI_PERMISSION を指定して context.grantUriPermission を呼び出します。 |
updateAppWidgetState の後もウィジェット UI が更新されない | updateAppWidgetState は DataStore に書き込みますが再コンポーズは行わないか、アクティブなセッション中に provideGlance が再度呼び出されていません。 | updateAppWidgetState の後に .update(context, glanceId) または .updateAll(context) を呼び出し、provideContent 内でリビジョン状態を監視します。 |
アクションのタップ時に NoSuchMethodException: <Class>.<init> [] が発生する | ActionCallback にコンストラクターパラメーターが宣言されているか、R8 によって <init>() が削除されています。 | public な引数なしコンストラクターを使用し、ActionParameters.Key で値を渡して、ProGuard で <init>() を保持します。 |
レシーバーで ForegroundServiceStartNotAllowedException が発生する | AppWidgetProvider がバックグラウンドで startForegroundService を呼び出しました。 | onUpdate で enqueueUniquePeriodicWork(ExistingPeriodicWorkPolicy.KEEP)を使って定期同期をスケジュールするか、onReceive でカスタムアクションのブロードキャストを受け取った際に、単発のワーク(enqueueUniqueWork)をキューに追加します。 |
Unresolved reference: ImageProvider、DpSize、updateAppWidgetState、または updateAll | Glance と Compose のパッケージインポートがない、または不一致です。または glanceId を指定せずに updateAppWidgetState(context) を呼び出しています。 | androidx.glance.ImageProvider(Bitmap 用)、androidx.compose.ui.unit.DpSize および androidx.compose.ui.unit.dp(SizeMode.Responsive 用)、androidx.glance.appwidget.state.updateAppWidgetState(context と glanceId の両方を渡す)、androidx.glance.appwidget.updateAll をインポートします。 |
避けるべきパターン
- 絶対に、
SizeMode.ResponsiveまたはSizeMode.Exactでサイズ区分ごとにprovideContent内でBitmapをデコードまたは拡大縮小しないでください。 - 絶対に、ウィジェットのビットマップを画面全体のメモリ上限(
6 * width * height)に合わせないでください。起動後に折りたたみ式端末を開いたり、ディスプレイ解像度を切り替えたりしても起動時の上限を超えないよう、必ず最初にウィジェットの目標maxWidthDp x maxHeightDpに合わせてダウンサンプリングしてください。 - 絶対に、スケーリングしていない
content://UriやUri.fromFileをImageProvider(uri)またはRemoteViews.setImageViewUriに渡したり、Intent.CATEGORY_HOMEに対するcontext.grantUriPermissionを省略したりしないでください。 - 絶対に、
GlanceAppWidget.update(context, glanceId)またはGlanceAppWidget.updateAll(context)を呼び出さずにupdateAppWidgetStateを呼び出さないでください。 - 絶対に、ウィジェット画像を置き換える際に
partiallyUpdateAppWidgetを呼び出さないでください。