关于 glance-appwidget-updates-and-memory
面向 Android 开发者的 Jetpack Glance 和 RemoteViews 小组件更新与内存管理技能,提供图片缩放、内存预算估算和状态更新指导。内容还涵盖多尺寸位图复用及图片替换时的更新方式。
主要功能
- 01估算小组件位图内存上限
- 02按目标尺寸缩小图片
- 03在多种尺寸间复用位图
- 04状态变化后触发小组件更新
- 05通过 FileProvider 分享图片 URI
适用场景
- 01控制 Android 17 小组件的图片内存占用
- 02为 Glance 多尺寸布局准备共享图片
- 03在 DataStore 状态修改后刷新小组件
- 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时,请参阅 创建应用小部件。
前提条件
- Kotlin Android 项目(
minSdk >= 26),并在build.gradle.kts中设置compileSdk = 37(以 Android 17 为目标时设置targetSdk = 37)。 - 启用 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+,可能导致活动窗口指标报告的尺寸大于启动时的上限,因此始终先将每张图像缩小到小部件的目标尺寸(maxWidthDp x maxHeightDp,采用RGB_565或ALPHA_8),并强制执行每张图像的字节数上限(calculateWidgetMemoryCapBytes(context) / distinctImageCount)。此上限根据 API 30 及更高版本上的WindowManager.currentWindowMetrics.bounds估算为6 * width * height;在 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)除以不同位图的数量。 - 缩小 content URI 对应的图像,并授予启动器读取权限:在 Android 17(
targetSdk >= 37)上,基于位图的Icon也会计入内存上限。传递给ImageProvider(uri)或RemoteViews.setImageViewUri的content://URI 不计入进程间通信(IPC)上限,但启动器会按原始分辨率解码这些图像。请将文件缩小后存入context.cacheDir,使用FileProvider.getUriForFile提供该文件,并向Intent.CATEGORY_HOME包授予Intent.FLAG_GRANT_READ_URI_PERMISSION。 - 状态变更后显式触发小部件更新:
updateAppWidgetState会将偏好设置写入 DataStore,但不会更新小部件 UI。变更状态后,调用GlanceAppWidget.update(context, glanceId)或GlanceAppWidget.updateAll(context)。 - 替换图像时使用完整的 updateAppWidget 调用:在
AppWidgetProvider类中替换位图或Icon时,请调用AppWidgetManager.updateAppWidget,而不要调用partiallyUpdateAppWidget。
参考实现文件
实现每个步骤时,请阅读 references/ 中的配套参考文件,并将示例 package 声明和类名更新为与你的应用一致:
- 位图裁剪与 FileProvider 辅助工具:WidgetBitmapUtils.kt
- 响应式小组件、接收器与错误回退:SampleGlanceWidget.kt
- LazyColumn 集合小组件与版本缓存:FeedCollectionGlanceWidget.kt
- DataStore 状态推送、CoroutineWorker 与 ActionCallback:WidgetStateAndActions.kt
- 传统 AppWidgetProvider、WorkManager 与完整更新:ClassicWidgetImageUpdater.kt
- Manifest、小组件信息、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 或库版本,请保留;否则,在gradle/libs.versions.toml中添加glance = "1.2.0",以及androidx.glance:glance-appwidget和androidx.glance:glance-material3。 - 在
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" />,以便queryIntentActivities在 API 30 及更高版本上发现启动器软件包。注册你的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 步:估算 widget 内存上限并对图像进行降采样
阅读 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 widget 或 workerContext调用WindowManager会记录一条非致命的StrictMode违规日志,这是此估算的预期行为)。在 API 26-29 中(或WindowManager不可用时),回退到context.resources.displayMetrics(6L * dm.widthPixels.toLong() * dm.heightPixels.toLong())。由于system_server会在启动时缓存一次6 * width * height,因此始终先根据 widget 的maxWidthDp x maxHeightDp调整图像大小(阶段 1),而不要占满全屏预算。 - 分两个阶段进行降采样(
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())。这样可以确保宽幅横幅或高截图不会因为某一轴已符合要求而绕过降采样。 - 其次,通过不断将
sampleSize *= 2强制执行单张图像的字节上限(perImageCapBytes = calculateWidgetMemoryCapBytes(context) / max(1, distinctImageCount)),直至(max(1, bounds.outWidth / sampleSize).toLong() * max(1, bounds.outHeight / sampleSize).toLong() * bytesPerPixel) > perImageCapBytes不再成立。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还是FileProviderUri(cacheWidgetImageUri):直接在内存中传递位图时,使用decodeDownsampledWidgetBitmap和androidx.glance.ImageProvider(bitmap)。当 widget 将content://Uri传递给ImageProvider(uri)或RemoteViews.setImageViewUri时,使用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(推荐在使用glance-canonical-layouts时采用,以针对当前网格跨度的纵向和横向方向渲染精确的LocalSize.current尺寸)、SizeMode.Responsive(RESPONSIVE_BUCKETS)(使用import androidx.compose.ui.unit.DpSize和import androidx.compose.ui.unit.dp预先打包固定的DpSize断点,例如setOf(DpSize(120.dp, 120.dp), DpSize(240.dp, 120.dp), DpSize(240.dp, 240.dp)),这样尺寸调整时就不会唤醒应用进程),或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),并调用AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, fallback),使用纯文本RemoteViews布局(0个位图)。
第 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缩略图(使用mapNotNull而非associate,以确保映射类型保持为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:推送状态更新、配置 ActionCallbacks,并使用 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类和ActionParameters.Key,并确保 R8 保留<init>()(安排后台工作时需要androidx.work:work-runtime-ktx:2.12.0):RunCallbackAction使用Class.getDeclaredConstructor().newInstance()实例化ActionCallback类,因此在任何构建中,构造函数参数都会导致失败。移除ActionCallback实现类的构造函数参数,使用ActionParameters.Key和actionParametersOf传递值(参见 WidgetStateAndActions.kt 中的RefreshWidgetAction),并将-keep class * implements androidx.glance.appwidget.action.ActionCallback { public <init>(); }添加到proguard-rules.pro(参见 proguard-rules.pro)。在SyncWidgetWorker.doWork()中,调用pushWidgetRevisionUpdate(applicationContext),以便后台工作更新 DataStore 并刷新小组件。 - 避免在
AppWidgetProvider.onUpdate中无条件安排一次性WorkManager任务,并对图像使用完整的updateAppWidget(参见 ClassicWidgetImageUpdater.kt):绝不要在AppWidgetProvider.onReceive或onUpdate中调用context.startForegroundService(这会抛出ForegroundServiceStartNotAllowedException)。GlanceAppWidgetReceiver已在内部保留一个延迟 10 年的会话工作器,因此 WorkManager 的RescheduleReceiver不会切换状态。在传统的AppWidgetProvider中,避免直接在onUpdate内安排OneTimeWorkRequest:即使使用ExistingWorkPolicy.KEEP,一次性工作器完成(SUCCEEDED)后,UnfinishedWorkListener会禁用RescheduleReceiver,后者会广播ACTION_PACKAGE_CHANGED并循环触发onUpdate。应改为在onUpdate中使用enqueueUniquePeriodicWork(..., ExistingPeriodicWorkPolicy.KEEP, ...)安排重复同步(该任务会在多次运行之间保持ENQUEUED状态);对于少于< 10s的短时更新,使用goAsync();或者仅在onReceive中接收到特定操作广播时,才安排一次性工作(enqueueUniqueWork(..., ExistingWorkPolicy.KEEP, ...))。在传统的RemoteViews小组件上更新Bitmap或Icon时,请调用appWidgetManager.updateAppWidget,而不是partiallyUpdateAppWidget。
常见错误及修复方法
| 症状或异常 | 根本原因 | 修复方法 |
|---|---|---|
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:// URI。 | 使用 FileProvider 暴露文件,为 CATEGORY_HOME 添加 <queries>,并使用 Intent.FLAG_GRANT_READ_URI_PERMISSION 调用 context.grantUriPermission。 |
调用 updateAppWidgetState 后小组件 UI 仍然冻结 | updateAppWidgetState 会写入 DataStore,但不会触发重组;或者在活跃会话期间未再次触发 provideGlance。 | 调用 .update(context, glanceId) 或 .updateAll(context),并在 provideContent 中观察 revision 状态。 |
点击操作时出现 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 包,或者调用 updateAppWidgetState(context) 时未传入 glanceId。 | 导入 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。 |
反模式
- 绝对不要在
provideContent中针对SizeMode.Responsive或SizeMode.Exact的每个尺寸区间解码或缩放Bitmap。 - 绝对不要将小组件位图调整到填满整个屏幕的内存上限(
6 * width * height);务必先将其降采样到小组件目标尺寸maxWidthDp x maxHeightDp,以免折叠屏展开或设备启动后切换显示分辨率时超过启动时的内存上限。 - 绝对不要将未经缩放的
content://Uri或Uri.fromFile传给ImageProvider(uri)或RemoteViews.setImageViewUri,也不要省略针对Intent.CATEGORY_HOME的context.grantUriPermission。 - 绝对不要调用
updateAppWidgetState而不调用GlanceAppWidget.update(context, glanceId)或GlanceAppWidget.updateAll(context)。 - 绝对不要在替换小组件图像时调用
partiallyUpdateAppWidget。
评论
支持 Markdown