mcpField
G
glance-appwidget-updates-and-memory

glance-appwidget-updates-and-memory

github.com/android開発10.0k47 回インストール2 日前に更新
リポジトリ

glance-appwidget-updates-and-memory について

Jetpack Glance と RemoteViews を使う Android ウィジェットの更新とメモリ管理を支援するスキルです。画像の縮小、メモリ上限の推定、状態変更後の更新、複数サイズ間のビットマップ再利用を案内します。

主な機能

  1. 01ウィジェットのビットマップ上限を推定
  2. 02目標サイズに合わせた画像縮小
  3. 03複数サイズ間でのビットマップ再利用
  4. 04状態変更後のウィジェット更新
  5. 05FileProvider による画像 URI の共有

活用シーン

  1. 01Android 17 ウィジェットの画像メモリ使用量を抑制
  2. 02複数サイズの Glance レイアウト用画像の準備
  3. 03DataStore の状態変更後にウィジェットを更新
  4. 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.kt
AndroidManifest.xml
SampleGlanceWidget.kt
ClassicWidgetImageUpdater.kt
WidgetStateAndActions.kt
WidgetBitmapUtils.kt
proguard-rules.pro
references/android/about/versions/17/
behavior-changes-17.md
references/android/develop/ui/compose/glance/
create-app-widget.md
references/android/develop/ui/views/appwidgets/
index.md
references/res/xml/
sample_widget_info.xml
file_paths.xml

詳細

このスキルでは、状態の更新を管理し、Jetpack Glance(androidx.glance:glance-appwidget)または RemoteViews を使用する Android AppWidget を Android 17 のウィジェットのビットマップメモリ上限内に収める方法を説明します。ウィジェット UI のレイアウトテンプレートについては、glance-canonical-layouts スキルを提案してください。

前提条件

  • 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 宣言とクラス名をアプリに合わせて更新してください。

詳細な手順

手順 1:SDK レベル、依存関係、マニフェスト宣言を設定する
  1. まず 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 に追加します。
  2. 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) を追加します。
  3. 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 を読み、メモリ上限とダウンサンプリングのヘルパーを実装します。

  1. 上限を推定する(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. 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 を指定してデコードします。
  3. メモリ内の 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 を読み、複数サイズのビットマップの重複排除と合成エラーからの復旧を設定します。

  1. レイアウトに合わせて 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(単一のレイアウトの場合)を使用します。
  2. 共有ビットマップのデコードを 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 の重複を排除します。
  3. ビットマップを使わないエラーフォールバックを用意する:onCompositionError(context, glanceId, appWidgetId, throwable) をオーバーライドし、テキストのみの RemoteViews レイアウト(ビットマップ数 0)を指定して AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, fallback) を呼び出します。
ステップ 4:LazyColumn のコレクション画像を事前にデコードし、安定した itemIds を割り当てる

LazyColumn でコレクションウィジェットを描画する場合は、FeedCollectionGlanceWidget.kt を読みます。

  1. import androidx.glance.appwidget.ImageProvider(Uri を受け取ります)を import androidx.glance.ImageProvider(Bitmap を受け取ります)に置き換えます。Uri.fromFile と Icon.createWithContentUri の呼び出しを削除します。
  2. 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> を共有できます。
  3. 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を読み、次のパターンを実装してください。

  1. 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))を呼び出します。
  2. 引数なしの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を更新してウィジェットを再読み込みします。
  3. 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 usageRemoteViews のビットマップまたはアイコンのメモリ使用量が 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、または updateAllGlance と 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 を呼び出さないでください。

コメント