mcpField
G
glance-appwidget-updates-and-memory

glance-appwidget-updates-and-memory

github.com/android开发10.0k47 次安装2 天前更新
代码仓库

关于 glance-appwidget-updates-and-memory

面向 Android 开发者的 Jetpack Glance 和 RemoteViews 小组件更新与内存管理技能,提供图片缩放、内存预算估算和状态更新指导。内容还涵盖多尺寸位图复用及图片替换时的更新方式。

主要功能

  1. 01估算小组件位图内存上限
  2. 02按目标尺寸缩小图片
  3. 03在多种尺寸间复用位图
  4. 04状态变化后触发小组件更新
  5. 05通过 FileProvider 分享图片 URI

适用场景

  1. 01控制 Android 17 小组件的图片内存占用
  2. 02为 Glance 多尺寸布局准备共享图片
  3. 03在 DataStore 状态修改后刷新小组件
  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 技能。

前提条件

  • 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 声明和类名更新为与你的应用一致:

详细步骤

步骤 1:配置 SDK 级别、依赖项和清单声明
  1. 首先阅读 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。
  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" />,以便 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,并实现内存上限和降采样辅助函数:

  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 widget 或 worker Context 调用 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),而不要占满全屏预算。
  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())。这样可以确保宽幅横幅或高截图不会因为某一轴已符合要求而绕过降采样。
    • 其次,通过不断将 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。
  3. 选择内存中的 Bitmap 还是 FileProvider Uri (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,了解如何配置多尺寸位图去重和组合错误恢复:

  1. 根据布局选择 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(用于单一布局)。
  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),并调用 AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, fallback),使用纯文本 RemoteViews 布局(0 个位图)。
第 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 缩略图(使用 mapNotNull 而非 associate,以确保映射类型保持为 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:推送状态更新、配置 ActionCallbacks,并使用 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 类和 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 并刷新小组件。
  3. 避免在 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 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:// 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>()。
接收器中出现 ForegroundServiceStartNotAllowedExceptionAppWidgetProvider 在后台调用了 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