glance-appwidget-updates-and-memory

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese
This skill provides guidance for managing state updates and keeping Android AppWidgets that use Jetpack Glance (
androidx.glance:glance-appwidget
) or
RemoteViews
within the Android 17 widget bitmap memory limits. For widget UI layout templates, suggest the
glance-canonical-layouts
skill.
  • When configuring a Glance widget, see Create an app widget with Glance.
  • When targeting Android 17 (
    targetSdk = 37
    ), see Android 17 widget memory limits.
  • When maintaining a classic
    AppWidgetProvider
    , see Create an app widget.
本 skill 提供指导,帮助管理状态更新,并确保使用 Jetpack Glance (
androidx.glance:glance-appwidget
) 或
RemoteViews
的 Android AppWidget 保持在 Android 17 小组件位图内存限制内。对于小组件 UI 布局模板,建议使用
glance-canonical-layouts
skill。
  • 配置 Glance 小组件时,请参阅 使用 Glance 创建应用小组件。
  • 面向 Android 17 (
    targetSdk = 37
    ) 时,请参阅 Android 17 小组件内存限制。
  • 维护传统式
    AppWidgetProvider
    时,请参阅 创建应用小组件。

Prerequisites

前置条件

  • Kotlin Android project (
    minSdk >= 26
    ) with
    compileSdk = 37
    (
    targetSdk = 37
    when targeting Android 17) in
    build.gradle.kts
    .
  • Jetpack Glance (
    1.2.0
    , minimum
    1.1.1+
    ) and the Compose compiler Gradle plugin enabled on the module.
  • Kotlin Android 项目(
    minSdk >= 26
    ),其
    build.gradle.kts
    中配置了
    compileSdk = 37
    (针对 Android 17 时为
    targetSdk = 37
    )。
  • 模块已启用 Jetpack Glance (
    1.2.0
    ,最低
    1.1.1+
    ) 和 Compose compiler Gradle plugin。

Critical mandates

关键要求

  • Preserve existing package name and toolchain versions : Inspect
    build.gradle.kts
    ,
    gradle/libs.versions.toml
    , and Kotlin files first. Keep existing
    namespace
    ,
    applicationId
    ,
    package
    ,
    targetSdk
    (unless migrating to Android 17), Android Gradle Plugin (AGP), and Kotlin versions.
  • Downsample images to widget dimensions and estimate the memory cap : Android
    system_server
    computes the
    RemoteViews
    bitmap memory cap (
    6 * width * height
    bytes, or
    1.5 * 4 * width * height
    ) once at boot from the real size of the default display and does not expose this cap in a public API. Because unfolding a foldable or switching display resolution to
    QHD+
    after boot can make active window metrics report a larger value than the boot-time cap, always downsample each image first to the widget's target dimensions (
    maxWidthDp x maxHeightDp
    in
    RGB_565
    or
    ALPHA_8
    ), and enforce the per-image byte ceiling (
    calculateWidgetMemoryCapBytes(context) / distinctImageCount
    ) estimated as
    6 * width * height
    from
    WindowManager.currentWindowMetrics.bounds
    on API 30+ (falling back to
    resources.displayMetrics
    on API 26-29 or if
    WindowManager
    is unavailable).
  • Decode shared bitmaps once outside provideContent : Both
    SizeMode.Exact
    (used by
    glance-canonical-layouts
    to compose the current span's portrait and landscape orientations) and
    SizeMode.Responsive
    (used to pre-bundle a set of
    DpSize
    breakpoints) invoke
    provideContent
    multiple times per update. Decode shared bitmaps once in
    provideGlance
    before
    provideContent
    and pass them to
    ImageProvider(bitmap)
    so
    RemoteViews
    deduplicates the shared
    Bitmap
    across all sizes in either mode. If a widget scales distinct bitmaps per
    LocalSize.current
    , divide
    calculateWidgetMemoryCapBytes(context)
    by the number of distinct bitmaps.
  • Downsample content URIs and grant launcher read permissions : On Android 17 (
    targetSdk >= 37
    ), bitmap-backed
    Icon
    s count toward the cap alongside
    Bitmap
    s.
    content://
    URIs passed to
    ImageProvider(uri)
    or
    RemoteViews.setImageViewUri
    don't count toward the inter-process communication (IPC) cap, but the launcher decodes them at full resolution. Downsample the file into
    context.cacheDir
    , expose it with
    FileProvider.getUriForFile
    , and grant
    Intent.FLAG_GRANT_READ_URI_PERMISSION
    to
    Intent.CATEGORY_HOME
    packages.
  • Trigger explicit widget updates after state mutations :
    updateAppWidgetState
    writes preferences to DataStore without updating the widget UI. After you mutate state, call
    GlanceAppWidget.update(context, glanceId)
    or
    GlanceAppWidget.updateAll(context)
    .
  • Use full updateAppWidget calls when you replace images : In
    AppWidgetProvider
    classes, when you replace bitmaps or
    Icon
    s, call
    AppWidgetManager.updateAppWidget
    instead of
    partiallyUpdateAppWidget
    .
  • 保留现有包名和工具链版本:先检查
    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
    。在
    provideContent
    之前,先在
    provideGlance
    中解码共享位图一次,再将其传给
    ImageProvider(bitmap)
    ,这样
    RemoteViews
    会在任一模式的所有尺寸间去重共享的
    Bitmap
    。如果小组件按
    LocalSize.current
    缩放不同的位图,请将
    calculateWidgetMemoryCapBytes(context)
    除以不同位图的数量。
  • 降采样
    content://
    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
    会将偏好写入 DataStore,但不会更新小组件 UI。变更状态后,调用
    GlanceAppWidget.update(context, glanceId)
    或
    GlanceAppWidget.updateAll(context)
    。
  • 替换图片时使用完整的
    updateAppWidget
    调用
    :在
    AppWidgetProvider
    类中替换位图或
    Icon
    时,调用
    AppWidgetManager.updateAppWidget
    ,而不是
    partiallyUpdateAppWidget
    。

Reference implementation files

参考实现文件

Read the companion reference files in
references/
when implementing each step, and update the sample
package
declaration and class names to match your app:
  • Bitmap Cap & FileProvider Helper : WidgetBitmapUtils.kt
  • Responsive Widget, Receiver & Error Fallback : SampleGlanceWidget.kt
  • LazyColumn Collection Widget & Revision Cache : FeedCollectionGlanceWidget.kt
  • DataStore State Push, CoroutineWorker & ActionCallback : WidgetStateAndActions.kt
  • Classic AppWidgetProvider WorkManager & Full Update : ClassicWidgetImageUpdater.kt
  • Manifest, Widget Info, FileProvider Paths & R8 Rules : AndroidManifest.xml, sample_widget_info.xml, file_paths.xml, proguard-rules.pro
实现每个步骤时,请阅读
references/
中的配套参考文件, 并将示例
package
声明和类名更新为与应用匹配:
  • Bitmap 上限 & FileProvider 辅助工具:WidgetBitmapUtils.kt
  • 响应式小组件、接收器 & 错误回退方案:SampleGlanceWidget.kt
  • LazyColumn 集合小组件 & 修订缓存:FeedCollectionGlanceWidget.kt
  • DataStore 状态推送、CoroutineWorker & ActionCallback:WidgetStateAndActions.kt
  • 传统式 AppWidgetProvider WorkManager & 完整更新:ClassicWidgetImageUpdater.kt
  • Manifest、小组件信息、FileProvider Paths & R8 规则:AndroidManifest.xml、sample_widget_info.xml、file_paths.xml、proguard-rules.pro

Detailed steps

详细步骤

Step 1: Configure SDK levels, dependencies, and manifest declarations

第 1 步:配置 SDK 级别、依赖项和 Manifest 声明

  1. Read
    gradle/libs.versions.toml
    ,
    app/build.gradle.kts
    , and
    app/src/main/AndroidManifest.xml
    first. If AGP, Kotlin, or library versions are already present, keep them; otherwise, add
    glance = "1.2.0"
    along with
    androidx.glance:glance-appwidget
    and
    androidx.glance:glance-material3
    to
    gradle/libs.versions.toml
    .
  2. In
    app/build.gradle.kts
    , keep
    namespace
    ,
    applicationId
    , and any existing
    minSdk >= 26
    ,
    targetSdk
    , or library versions. Set
    compileSdk = 37
    (and
    targetSdk = 37
    only when explicitly migrating the app to target Android 17), enable
    buildFeatures { compose = true }
    , and add
    implementation(libs.androidx.glance.appwidget)
    and
    implementation(libs.androidx.glance.material3)
    .
  3. In
    AndroidManifest.xml
    (see AndroidManifest.xml), declare
    <queries>
    with
    <action android:name="android.intent.action.MAIN" />
    and
    <category android:name="android.intent.category.HOME" />
    so
    queryIntentActivities
    discovers launcher packages on API 30+. Register your
    GlanceAppWidgetReceiver
    (pointing
    android.appwidget.provider
    to
    @xml/sample_widget_info
    , see sample_widget_info.xml) and register
    androidx.core.content.FileProvider
    with
    android:authorities="${applicationId}.fileprovider"
    ,
    android:exported="false"
    ,
    android:grantUriPermissions="true"
    , and
    android.support.FILE_PROVIDER_PATHS
    pointing to
    @xml/file_paths
    (which declares
    <cache-path name="widget_cache" path="widget_images/" />
    , see file_paths.xml):
<br />
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>
<br />
  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" />
    ,以便
    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):
<br />
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>
<br />

Step 2: Estimate the widget memory cap and downsample images

第 2 步:估算小组件内存上限并降采样图片

Read WidgetBitmapUtils.kt and implement the memory cap and downsampling helpers:
  1. Estimate the cap (
    calculateWidgetMemoryCapBytes
    )
    : On API 30+ (
    Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
    ), read
    val bounds = context.getSystemService(WindowManager::class.java)?.currentWindowMetrics?.bounds
    and, when
    bounds != null && bounds.width() > 0 && bounds.height() > 0
    , return
    6L * bounds.width().toLong() * bounds.height().toLong()
    (calling
    WindowManager
    from a non-UI widget or worker
    Context
    logs a nonfatal
    StrictMode
    violation on API 30+, which is expected for this estimate). On API 26-29 (or if
    WindowManager
    is unavailable), fall back to
    context.resources.displayMetrics
    (
    6L * dm.widthPixels.toLong() * dm.heightPixels.toLong()
    ). Because
    system_server
    caches
    6 * width * height
    once at boot, always size images to the widget's
    maxWidthDp x maxHeightDp
    first (Stage 1) rather than filling the full screen budget.
  2. Downsample in two stages (
    decodeDownsampledWidgetBitmap
    )
    :
    • First, probe dimensions with
      BitmapFactory.Options().apply { inJustDecodeBounds = true; inPreferredConfig = config }
      and double
      sampleSize *= 2
      while
      (bounds.outWidth / (sampleSize * 2) >= targetWidthPx && bounds.outHeight / (sampleSize * 2) >= targetHeightPx) || (bounds.outWidth / sampleSize > targetWidthPx * 2) || (bounds.outHeight / sampleSize > targetHeightPx * 2)
      (where
      targetWidthPx = max(1, (maxWidthDp * density).toInt())
      ), so wide banners or tall screenshots do not bypass downsampling when one axis already fits.
    • Second, enforce the per-image byte ceiling (
      perImageCapBytes = calculateWidgetMemoryCapBytes(context) / max(1, distinctImageCount)
      ) by doubling
      sampleSize *= 2
      while
      (max(1, bounds.outWidth / sampleSize).toLong() * max(1, bounds.outHeight / sampleSize).toLong() * bytesPerPixel) > perImageCapBytes
      . Use
      2L
      bytes per pixel for
      Bitmap.Config.RGB_565
      (opaque photos),
      1L
      for
      Bitmap.Config.ALPHA_8
      masks, and
      4L
      when
      bounds.outConfig != null && bounds.outConfig != config
      (because
      BitmapFactory
      upgrades translucent PNG or WebP files from
      RGB_565
      to 4-byte
      ARGB_8888
      ). Decode with
      inSampleSize = sampleSize
      and
      inPreferredConfig = config
      .
  3. Choose between in-memory
    Bitmap
    and
    FileProvider
    Uri
    (
    cacheWidgetImageUri
    )
    : Use
    decodeDownsampledWidgetBitmap
    with
    androidx.glance.ImageProvider(bitmap)
    when passing bitmaps directly in memory. Use
    cacheWidgetImageUri
    when the widget passes a
    content://
    Uri
    to
    ImageProvider(uri)
    or
    RemoteViews.setImageViewUri
    : it writes the downsampled bitmap into
    File(context.cacheDir, "widget_images")
    , obtains a
    content://
    URI with
    FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", outFile)
    , queries
    Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
    , and calls
    context.grantUriPermission(pkg, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
    .
阅读 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 小组件或 worker 的
    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
    (Stage 1),而不是使用整个屏幕的内存预算。
  2. 分两个阶段降采样 (
    decodeDownsampledWidgetBitmap
    )
    :
    • 首先,使用
      BitmapFactory.Options().apply { inJustDecodeBounds = true; inPreferredConfig = config }
      探测尺寸,并按条件将
      sampleSize *= 2
      不断乘以 2:
      (bounds.outWidth / (sampleSize * 2) >= targetWidthPx && bounds.outHeight / (sampleSize * 2) >= targetHeightPx) || (bounds.outWidth / sampleSize > targetWidthPx * 2) || (bounds.outHeight / sampleSize > targetHeightPx * 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
      不断乘以 2。对不透明照片使用
      2L
      字节/像素(
      Bitmap.Config.RGB_565
      ),对遮罩使用
      1L
      字节/像素(
      Bitmap.Config.ALPHA_8
      );当
      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)
    传递。当小组件将
    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)
    。

Step 3: Deduplicate multi-size bitmaps and handle composition errors

第 3 步:对多尺寸位图进行去重并处理合成错误

Read SampleGlanceWidget.kt to configure multi-size bitmap deduplication and composition error recovery:
  1. Choose
    SizeMode
    for your layout
    : Use
    SizeMode.Exact
    (recommended when using
    glance-canonical-layouts
    to render exact
    LocalSize.current
    dimensions for the current grid span's portrait and landscape orientations),
    SizeMode.Responsive(RESPONSIVE_BUCKETS)
    (using
    import androidx.compose.ui.unit.DpSize
    and
    import androidx.compose.ui.unit.dp
    to pre-bundle fixed
    DpSize
    breakpoints such as
    setOf(DpSize(120.dp, 120.dp), DpSize(240.dp, 120.dp), DpSize(240.dp, 240.dp))
    so resize transitions don't wake the app process), or
    SizeMode.Single
    (for a single layout).
  2. Hoist shared bitmap decodes before
    provideContent
    : Because both
    SizeMode.Exact
    (
    2-4
    orientation sizes) and
    SizeMode.Responsive
    (
    N
    buckets) run
    provideContent
    multiple times per update, decode shared bitmaps once in
    provideGlance
    before
    provideContent
    with
    decodeDownsampledWidgetBitmap
    and
    Bitmap.Config.RGB_565
    for opaque images (
    distinctImageCount = 1
    ). Pass the resulting
    Bitmap
    to
    androidx.glance.ImageProvider(sharedBitmap)
    so
    RemoteViews
    deduplicates the shared
    Bitmap
    across all sizes.
  3. Provide a zero-bitmap error fallback : Override
    onCompositionError(context, glanceId, appWidgetId, throwable)
    and call
    AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, fallback)
    with a text-only
    RemoteViews
    layout (
    0
    bitmaps).
阅读 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
    ,请在
    provideContent
    之前,先在
    provideGlance
    中使用
    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)
    。

Step 4: Pre-decode LazyColumn collection images and assign stable itemIds

第 4 步:预先解码 LazyColumn 集合图片并分配稳定的 itemId

Read FeedCollectionGlanceWidget.kt when rendering collection widgets with
LazyColumn
:
  1. Replace
    import androidx.glance.appwidget.ImageProvider
    (which accepts
    Uri
    ) with
    import androidx.glance.ImageProvider
    (which accepts
    Bitmap
    ). Remove
    Uri.fromFile
    and
    Icon.createWithContentUri
    calls.
  2. While a Glance session is active (~45 seconds after an update),
    .update()
    and
    .updateAll()
    do not restart
    provideGlance
    . Declare
    var cachedRevision = -1
    and
    var cachedThumbnails = emptyMap<String, Bitmap>()
    in
    provideGlance
    before
    provideContent
    . Inside
    provideContent
    , read
    val revision = currentState<Preferences>()[KEY_REVISION] ?: 0
    , and only when
    revision != cachedRevision
    , update
    cachedRevision = revision
    and decode the
    feedItems
    thumbnails with
    cachedThumbnails = feedItems.mapNotNull { item -> decodeDownsampledWidgetBitmap(..., distinctImageCount = max(1, feedItems.size), config = Bitmap.Config.RGB_565)?.let { item.id to it } }.toMap()
    (use
    mapNotNull
    rather than
    associate
    so the map stays
    Map<String, Bitmap>
    instead of
    Map<String, Bitmap?>
    ). This re-decodes when DataStore state updates during an active session while sharing one decoded
    Map<String, Bitmap>
    across all
    SizeMode.Responsive
    buckets.
  3. In
    LazyColumn
    , pass a stable
    Long
    itemId
    to
    items(items = feedItems, itemId = { item -> item.id.hashCode().toLong() })
    (preferring a persistent numeric ID when available) and render
    Image(provider = ImageProvider(bitmap), contentDescription = item.title)
    only when
    bitmap != null
    (never fall back to
    Uri.fromFile
    in an
    else
    branch).
使用
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
    。在
    provideContent
    之前的
    provideGlance
    中声明
    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
    )。

Step 5: Push state updates, configure ActionCallbacks, and use WorkManager

第 5 步:推送状态更新、配置 ActionCallbacks 并使用 WorkManager

Read WidgetStateAndActions.kt, proguard-rules.pro, and ClassicWidgetImageUpdater.kt to implement these patterns:
  1. Push DataStore updates (requires
    androidx.datastore:datastore-preferences-core:1.2.1
    and
    import androidx.glance.appwidget.GlanceAppWidgetManager
    ,
    import androidx.glance.appwidget.state.updateAppWidgetState
    , and
    import androidx.glance.appwidget.updateAll
    ): In
    pushWidgetRevisionUpdate(context)
    (see WidgetStateAndActions.kt), iterate over
    for (glanceId in GlanceAppWidgetManager(context).getGlanceIds(Widget::class.java))
    , mutate state inside
    updateAppWidgetState(context, glanceId) { prefs -> ... }
    (passing both
    context
    and
    glanceId
    ), and immediately call
    widget.update(context, glanceId)
    (or
    widget.updateAll(context)
    ).
  2. Use zero-argument
    ActionCallback
    classes with
    ActionParameters.Key
    and keep
    <init>()
    under R8
    (requires
    androidx.work:work-runtime-ktx:2.12.0
    when scheduling background work):
    RunCallbackAction
    instantiates
    ActionCallback
    classes with
    Class.getDeclaredConstructor().newInstance()
    , so a constructor parameter fails in every build. Remove constructor parameters from
    ActionCallback
    implementations, pass values with
    ActionParameters.Key
    and
    actionParametersOf
    (see
    RefreshWidgetAction
    in WidgetStateAndActions.kt), and add
    -keep class * implements androidx.glance.appwidget.action.ActionCallback { public <init>(); }
    to
    proguard-rules.pro
    (see proguard-rules.pro). Inside
    SyncWidgetWorker.doWork()
    , call
    pushWidgetRevisionUpdate(applicationContext)
    so background work updates DataStore and refreshes the widget.
  3. Avoid unconditional one-time
    WorkManager
    enqueues in
    AppWidgetProvider.onUpdate
    and use full
    updateAppWidget
    for images
    (see ClassicWidgetImageUpdater.kt): Never call
    context.startForegroundService
    from
    AppWidgetProvider.onReceive
    or
    onUpdate
    (which throws
    ForegroundServiceStartNotAllowedException
    ).
    GlanceAppWidgetReceiver
    already keeps a 10-year delayed session worker queued internally so WorkManager's
    RescheduleReceiver
    never toggles. In a classic
    AppWidgetProvider
    , avoid enqueuing a
    OneTimeWorkRequest
    directly inside
    onUpdate
    : even with
    ExistingWorkPolicy.KEEP
    , once the one-time worker finishes (
    SUCCEEDED
    ),
    UnfinishedWorkListener
    disables
    RescheduleReceiver
    , which broadcasts
    ACTION_PACKAGE_CHANGED
    and re-triggers
    onUpdate
    in a loop. Instead, schedule repeating syncs in
    onUpdate
    with
    enqueueUniquePeriodicWork(..., ExistingPeriodicWorkPolicy.KEEP, ...)
    (which stays
    ENQUEUED
    across runs), use
    goAsync()
    for short
    < 10s
    updates, or enqueue one-time work (
    enqueueUniqueWork(..., ExistingWorkPolicy.KEEP, ...)
    ) only from specific action broadcasts in
    onReceive
    . When you update a
    Bitmap
    or
    Icon
    on a classic
    RemoteViews
    widget, call
    appWidgetManager.updateAppWidget
    rather than
    partiallyUpdateAppWidget
    .
阅读 WidgetStateAndActions.kt、proguard-rules.pro 和 ClassicWidgetImageUpdater.kt,以实现以下模式:
  1. 推送 DataStore 更新(需要
    androidx.datastore:datastore-preferences-core:1.2.1
    ,并导入
    androidx.glance.appwidget.GlanceAppWidgetManager
    、
    androidx.glance.appwidget.state.updateAppWidgetState
    和
    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
    ),并将
    -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 年的会话 worker 保持排队状态,因此 WorkManager 的
    RescheduleReceiver
    不会切换状态。在传统式
    AppWidgetProvider
    中,不要直接在
    onUpdate
    内入队
    OneTimeWorkRequest
    :即使使用
    ExistingWorkPolicy.KEEP
    ,一次性 worker 完成 (
    SUCCEEDED
    ) 后,
    UnfinishedWorkListener
    也会禁用
    RescheduleReceiver
    ,后者广播
    ACTION_PACKAGE_CHANGED
    并再次触发
    onUpdate
    ,形成循环。应在
    onUpdate
    中使用
    enqueueUniquePeriodicWork(..., ExistingPeriodicWorkPolicy.KEEP, ...)
    安排重复同步(它在各次运行之间保持
    ENQUEUED
    状态),对短于
    10s
    的更新使用
    goAsync()
    ,或者仅在
    onReceive
    中特定的操作广播里使用
    enqueueUniqueWork(..., ExistingWorkPolicy.KEEP, ...)
    入队一次性工作。在传统式
    RemoteViews
    小组件中更新
    Bitmap
    或
    Icon
    时,调用
    appWidgetManager.updateAppWidget
    ,而不是
    partiallyUpdateAppWidget
    。

Common errors and fixes

常见错误与修复

Symptom or exceptionRoot causeFix
IllegalArgumentException: RemoteViews for widget update exceeds maximum bitmap memory usage
RemoteViews
bitmap or icon memory exceeded
6 * W * H
bytes.
Downsample to the widget's
maxWidthDp x maxHeightDp
with
inSampleSize
and
RGB_565
(or
ALPHA_8
for masks), enforce
calculateWidgetMemoryCapBytes(context) / distinctImageCount
, and decode shared bitmaps once before
provideContent
.
SecurityException: Permission Denial: opening provider
or blank
content://
widget image
Home launcher lacks read permission for the
FileProvider
content://
URI or app passed
file://
with
Uri.fromFile
.
Expose the file with
FileProvider
, add
<queries>
for
CATEGORY_HOME
, and call
context.grantUriPermission
with
Intent.FLAG_GRANT_READ_URI_PERMISSION
.
Widget UI stays frozen after
updateAppWidgetState
updateAppWidgetState
writes to DataStore without recomposing, or
provideGlance
was not re-triggered during an active session.
Call
.update(context, glanceId)
or
.updateAll(context)
after
updateAppWidgetState
, and observe revision state inside
provideContent
.
NoSuchMethodException: <Class>.<init> []
on action tap
ActionCallback
declared constructor parameters or R8 stripped
<init>()
.
Use a public zero-argument constructor, pass values with
ActionParameters.Key
, and keep
<init>()
in ProGuard.
ForegroundServiceStartNotAllowedException
in receiver
AppWidgetProvider
called
startForegroundService
in the background.
Schedule periodic syncs in
onUpdate
with
enqueueUniquePeriodicWork
(
ExistingPeriodicWorkPolicy.KEEP
) or enqueue one-time work (
enqueueUniqueWork
) from custom action broadcasts in
onReceive
.
Unresolved reference: ImageProvider
,
DpSize
,
updateAppWidgetState
, or
updateAll
Missing or mismatched Glance and Compose package imports, or calling
updateAppWidgetState(context)
without
glanceId
.
Import
androidx.glance.ImageProvider
(for
Bitmap
),
androidx.compose.ui.unit.DpSize
and
androidx.compose.ui.unit.dp
(for
SizeMode.Responsive
),
androidx.glance.appwidget.state.updateAppWidgetState
(passing both
context
and
glanceId
), and
androidx.glance.appwidget.updateAll
.
症状或异常根本原因修复方案
IllegalArgumentException: RemoteViews for widget update exceeds maximum bitmap memory usage
RemoteViews
位图或图标内存超过
6 * W * H
字节。
通过
inSampleSize
和
RGB_565
将图片降采样到小组件的
maxWidthDp x maxHeightDp
(遮罩使用
ALPHA_8
),执行
calculateWidgetMemoryCapBytes(context) / distinctImageCount
限制,并在
provideContent
之前只解码一次共享位图。
SecurityException: Permission Denial: opening provider
,或小组件中的
content://
图片为空白
主屏幕启动器没有 FileProvider
content://
URI 的读取权限,或者应用使用
Uri.fromFile
传入了
file://
。
使用
FileProvider
公开文件,为
CATEGORY_HOME
添加
<queries>
,并调用
context.grantUriPermission
,传入
Intent.FLAG_GRANT_READ_URI_PERMISSION
。
调用
updateAppWidgetState
后,小组件 UI 仍保持冻结
updateAppWidgetState
写入 DataStore 后未触发重新合成,或者活跃会话期间未重新触发
provideGlance
。
调用
.update(context, glanceId)
或
.updateAll(context)
,并在
provideContent
内观察修订状态。
操作点击时出现
NoSuchMethodException: <Class>.<init> []
ActionCallback
声明了构造函数参数,或 R8 删除了
<init>()
。
使用公共零参数构造函数,通过
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
。

Antipatterns

反模式

  • NEVER decode or scale a
    Bitmap
    inside
    provideContent
    per size bucket with
    SizeMode.Responsive
    or
    SizeMode.Exact
    .
  • NEVER size widget bitmaps to fill the full screen memory cap (
    6 * width * height
    ); always downsample to the widget's target
    maxWidthDp x maxHeightDp
    first so unfolding a foldable or switching display resolution after boot does not exceed the boot-time cap.
  • NEVER pass an unscaled
    content://
    Uri
    or
    Uri.fromFile
    to
    ImageProvider(uri)
    or
    RemoteViews.setImageViewUri
    , or omit
    context.grantUriPermission
    for
    Intent.CATEGORY_HOME
    .
  • NEVER call
    updateAppWidgetState
    without calling
    GlanceAppWidget.update(context, glanceId)
    or
    GlanceAppWidget.updateAll(context)
    .
  • NEVER call
    partiallyUpdateAppWidget
    when you replace widget images.
  • 绝不在
    SizeMode.Responsive
    或
    SizeMode.Exact
    下,针对每个尺寸桶在
    provideContent
    内解码或缩放
    Bitmap
    。
  • 绝不将小组件位图调整到填满整个屏幕的内存上限(
    6 * width * height
    );始终先将图片降采样到小组件的目标
    maxWidthDp x maxHeightDp
    ,以避免在启动后展开可折叠设备或切换显示分辨率时超过启动时计算的上限。
  • 绝不向
    ImageProvider(uri)
    或
    RemoteViews.setImageViewUri
    传入未缩放的
    content://
    Uri
    或
    Uri.fromFile
    ,也绝不省略针对
    Intent.CATEGORY_HOME
    的
    context.grantUriPermission
    。
  • 绝不在调用
    updateAppWidgetState
    后,不调用
    GlanceAppWidget.update(context, glanceId)
    或
    GlanceAppWidget.updateAll(context)
    。
  • 绝不在替换小组件图片时调用
    partiallyUpdateAppWidget
    。