Skip to content

Widget Rendering

lingion edited this page Sep 12, 2026 · 1 revision

桌面组件渲染技术

TL;DR:同步 RemoteViews + Canvas 渲染管线。Receiver 后台拉数据 → 画 bitmap → awm.updateAppWidget。长内容走可滚动壳图 + 条带 ListView。配色与 app 主题同源。

渲染架构

五款生产桌面组件(标签取自 strings.xml:「今日课程」「今日课程 · 小」「最近两天」「本周课表(列表)」「本周课表(周视图)」「本周课表(网格)」,app/src/main/res/values/strings.xml:3-12)统一走同步渲染路径:

AppWidgetProvider.onUpdate / onAppWidgetOptionsChanged
   └─ goAsync()                                              [续命广播, 防 5s ANR]
       └─ ioScope.launch { push(...) }                        [SupervisorJob + Dispatchers.Default]
           ├─ WidgetResizeCore.bump(id)                       [世代号 +1, 防 resize/更新乱序]
           ├─ Receiver.loadDataSync(context, id)              [runBlocking 读 Room]
           ├─ WidgetBitmapRenderers.renderXxx(...)            [Canvas → ARGB_8888 Bitmap]
           └─ RemoteViewsWidgetHelper.renderAndPush(...)      [RemoteViews.setImageViewBitmap + updateAppWidget]

关键代码:

  • app/src/main/java/com/lingion/sleepy/widget/TodayWidget.kt:65,onUpdate 用 goAsync() 续命,启动协程渲染。
  • app/src/main/java/com/lingion/sleepy/widget/RemoteViewsWidgetHelper.kt:76,renderAndPush 串起「同步加载 → 渲染 → 推送」三步。
  • app/src/main/java/com/lingion/sleepy/widget/TodayWidget.kt:513,loadDataSync 用 runBlocking 在后台协程里跑 DB 查询。

为什么走 RemoteViews 而不用 Glance

Glance 依赖 provideGlance 异步 SessionWorker 渲染。在 OPPO 的 OplusHansManager 冻结窗口内,SessionWorker 可被冻结,RemoteViews 就不会生成,组件停在加载布局,也无法跟随主题。同步路径在主进程里 goAsync 续命,冻结窗口之前就完成 updateAppWidget。

现状:app/src/main/java/com/lingion/sleepy/widget/WidgetContent.kt:15 注释写明「Glance composable 层已删除:5 个生产入口全走 RemoteViews + Canvas bitmap」,原 WeekListContent/TwoDayContent/WeekGridContent 等 composable 一并移除。

尺寸获取与 fitXY

桌面组件按当前 cell 真实尺寸渲染 bitmap。bitmap 宽高与容器宽高一致时 fitXY 等价于 1:1 像素映射,无拉伸、无黑边、无畸变。app/src/main/res/layout/widget_bitmap_container.xml:5 注释直接点明这条契约:android:scaleType="fitXY" + android:adjustViewBounds="false"。

OPTION_APPWIDGET_SIZES 定向选择

API 31+ 在横竖双向 widget 上返回 portrait/landscape 两份尺寸候选,面积最大者不一定等于当前方向。选错方向则 shell bitmap 按错误宽高画,launcher fitXY 强拉后内容变形。

app/src/main/java/com/lingion/sleepy/widget/WidgetSizeCore.kt:24 实现定向策略:

1. 镜像判据: 宽度差 ≤2dp 的两份 = 同一方向两份 (OEM 镜像) → 取高度小者
2. 有 hint (OPTION_APPWIDGET_MIN_WIDTH/MIN_HEIGHT, 当前 cell 宽高下界):
     取与 hint 长宽比同向、且两边差之和最小 (贴合度优先, 面积次之)
3. 无 hint: 默认竖放, h≥w 的份里取宽度最大

回退路径走 fallbackSizeDp(API<31 / launcher 未给 SIZES)。app/src/main/java/com/lingion/sleepy/widget/WidgetSizeCore.kt:66 用 MIN × MIN 同源边界,旧实现混拼 MIN_W × MAX_H 会把 1 行 widget 画成 5 行长图,已修。

调用入口在 app/src/main/java/com/lingion/sleepy/widget/RemoteViewsWidgetHelper.kt:35,computeSizeDp 按 API 33 守卫分别走 getParcelableArrayList(key, Class)(类型化)与 getParcelableArrayList<SizeF>(旧无类型重载,需 @Suppress("DEPRECATION"))。

Bitmap 渲染管线

数据模型

app/src/main/java/com/lingion/sleepy/widget/WidgetContent.kt:25 定义 WidgetData,字段为 date / courses / timeJson / hasTable / isDark / themeKey / semesterStatus / isToday。WeekData 与 TwoDayData 在同文件:app/src/main/java/com/lingion/sleepy/widget/WidgetContent.kt:150 与 WidgetContent.kt:163。Receiver 不画图,只负责组装数据喂给渲染器。

渲染器

app/src/main/java/com/lingion/sleepy/widget/WidgetBitmapRenderers.kt:27 单例:renderToday / renderTwoDay / renderWeekList / renderWeekView / renderNavTriangle。每个函数签名为 (Context, T, wDp, hDp) → Bitmap。

渲染流程固定三步:

  1. 按 density 算像素宽高 → Bitmap.createBitmap(w, h, ARGB_8888)
  2. 拿 scheme(见配色一节)
  3. Canvas 画背景圆角 + 内容(标题行 / 课程胶囊 / 状态行)

冲突课程走 ConflictLayoutEngine.weekLaneRows 分栏:同一时段多课程按冲突并排半栏,栏间浅细竖线分隔。WidgetBitmapRenderers.kt:431 是 Today 分栏调用点;WidgetBitmapRenderers.kt:1255 是 TwoDay 同引擎。

变体档位 WidgetVariant(app/src/main/java/com/lingion/sleepy/widget/WidgetVariant.kt:3)决定紧凑档 vs 全量排版。SMALL + 容器 <150dp → 紧凑档(纯文本,无课程胶囊);否则升档回全量排版。

Bitmap 内存契约

app/src/main/java/com/lingion/sleepy/widget/RemoteViewsWidgetHelper.kt:111 详细注释:setImageViewBitmap 把 bitmap 放进 RemoteViews.mBitmapCache,经 binder 传给系统 AppWidgetService,常以 ashmem 共享方式持有。本进程 bmp.recycle() 立刻释放 native pixel memory,启动器渲染时 setImageBitmap 抛「trying to use a recycled bitmap」→ apply() 失败 → 回落到「无法加载微件」错误视图。结论:不 recycle,交 GC 跟 mBitmapCache 一起回收。

可滚动条带:ScrollStripService

内容超出容器高度时启用可滚动分支。结构是壳图 + 条带 ListView 双图层:

FrameLayout (clipToOutline 圆角裁剪)
├── ImageView #widget_shell        壳图 = 原渲染器按容器尺寸画 = 圆角背景 + 首屏内容
└── ListView   #widget_strip_list  条带 = 原渲染器按全展开高画长图后横切
                                    条带与壳同源 → 滚动位 0 与主分支静态渲染像素一致

app/src/main/res/layout/widget_scroll_today.xml:5 即此结构(FrameLayout + ImageView + ListView)。RemoteViewsWidgetHelper.kt:137 的 pushScrollable 推送壳图 + 远程适配器 + 模板 PendingIntent。

v3 契约:单 child 整张长图 + 显式行高

app/src/main/java/com/lingion/sleepy/widget/ScrollStripService.kt:14 注释记录三次实现:

  • v1:多 child 48dp 切片 → launcher ListView extent 冻结于首屏 fill 数(extent = ceil(V/H)*H − V,OPPO 实测 4×125.5−386=116px 吻合)→ 尾条带不可达。
  • v2:单 child wrap_content + adjustViewBounds → launcher 量出错高(等比应 626px 量成 270px)→ fitXY 压扁 + 渲染错乱。
  • v3:单 child 整张长图 + setViewLayoutHeight(API31+)按位图真实 dp 显式钉行高 → launcher 拿到确定行高,不再依赖 wrap_content 量测。

ScrollStripService.kt:174 在 getViewAt 中调用 setViewLayoutHeight(R.id.widget_row_bitmap, bmp.height / density, TypedValue.COMPLEX_UNIT_DIP)。widget_scroll_row.xml:18 的固定值 386dp 只服务 API<31 与 fallback,不做自适应量测。

世代号闸门

app/src/main/java/com/lingion/sleepy/widget/RemoteViewsWidgetHelper.kt:106 与 ScrollStripService.kt:100 双层校验:渲染前 bump 一个世代号,渲染期间若又落新触发则校验失败丢弃旧结果。WidgetResizeCore.bump(id)(WidgetResizeCore.kt:26)是单原子计数器。resize 拖拽连续变化时只保留最新 commit 的结果。

主题色解析:resolveSchemePublic 三分支

所有桌面组件渲染共用一个取色入口,app/src/main/java/com/lingion/sleepy/widget/WidgetContent.kt:82:

themeKey
  ├─ "system" (跟随系统) → Material You 动态取色 (dynamicLight/DarkColorScheme, API 31+)
  ├─ "custom:<id>"        → CustomThemeStore.getById + CustomSchemeDeriver.derive
  └─ 其他                  → ThemePresets.byKey(themeKey) 取预设 light/dark

每条分支解析出的 WakeUpColorScheme 转成 WidgetScheme(bg / primary / primaryContainer / onPrimaryContainer / onSurface / onSurfaceVariant / surfaceContainer / surfaceVariant / isDark)。

"system" 分支的前提是 themeKey == "system" 能被识别:WidgetContent.kt:73-75 注释说明,resolveSchemePublic 必须显式处理这个 key,否则未知 key 一律落 Default 紫色预设,桌面组件不跟随壁纸取色。system 分支还需要 Build.VERSION.SDK_INT >= S 守卫,低版本降级 Default。custom:<id> 分支读不到已删除的自定义主题时,同样回落 Default,与 App 端 unknown-key 语义一致。

app/src/main/java/com/lingion/sleepy/widget/WidgetBitmapRenderers.kt:49 在 scheme(context, themeKey, isDark) 中调用此函数,把 Compose Color 转 ARGB Int 后写入 Scheme 数据类。WeekGridWidgetProvider.kt:163 也是同一入口:resolveSchemePublic(context, data.themeKey, isDark)。

黄金角课程配色

课程胶囊背景走黄金角 HSL,app/src/main/java/com/lingion/sleepy/util/CourseColorUtil.kt:30 的常量 GOLDEN_ANGLE = 137.508f,相邻 id 色差最大化(13 门课最少差约 27°)。

三层结构(决策 D3)

  1. 常量层:GOLDEN_ANGLE / SENTINEL_COLOR / S_LIGHT(0.55) / S_DARK(0.40) / L_LIGHT(0.82) / L_DARK(0.28)
  2. 纯逻辑层:stableHue(groupId) 用 groupId.hashCode()(课程身份标识)算 hue,不能换成 course.id(数据库自增主键,随导入漂移)
  3. 平台适配层:pickCourseColorCompose / pickCourseColorInt 两套返回类型,同一份逻辑

三态决策树(WidgetBitmapRenderers 调用入口)

WidgetBitmapRenderers.kt:81 用 CourseColorUtil.pickCourseColorIntWithGroupRows(course, groupRows, scheme.isDark, scheme.surfaceVariant, colorless):

1. colorMode=CUSTOM            → Color.parseColor(course.color)
2. colorMode=AUTO              → GoldenAngleColor.forRow(row, groupRows, groupSourceColorHex)
                                  基色 = 组色源色相 + 行号 × 137.508°
                                  同组同名课永远同起点, 行号递增自然分色
3. colorMode=GROUP (默认)      → 黄金角 groupId 散列 (旧默认, 同 groupId 永远同色)
                                  自定义色 (非 SENTINEL) 优先; colorless 灰底收尾

colorless=true 时返回 surfaceVariant(网格线色,保持一致)。亮色主题饱和度 0.55 / 亮度 0.82(柔和粉彩),暗色 0.40 / 0.28(沉稳低饱和)。

文字色亮度自适应

app/src/main/java/com/lingion/sleepy/util/CourseColorUtil.kt:83 实现 BT.601 加权亮度(0.299R + 0.587G + 0.114B)。textColorOn(bg, isDark, onSurface) 决策:

luminance < 0.5f        → 白字    (深色自定义课色)
浅色底 + 暗色主题        → 黑字
浅色底 + 浅色主题        → onSurface (HSL 默认底恰好可读, 行为不变)

WidgetBitmapRenderers.kt:83 给胶囊文字上色,WidgetBitmapRenderers.kt:889 在 WeekList 课程行复用同一入口。

跨厂商一致性

WidgetBitmapRenderers / WeekGridWidgetProvider / TodayScreen / CourseTableView 四处课程配色已收敛到 CourseColorUtil,不再有私有 hslToColorInt / pickCourseColor 副本。WidgetBitmapRenderers.kt:67 注释:「之前用 resolveCourseColorKey 关键词分类 → 与首页/WeekGrid 色系不一致, 已废弃」。

关键调用路径

WidgetUpdateWorker 触发
   └─ AppWidgetManager 回调 onUpdate / onAppWidgetOptionsChanged
       └─ TodayWidgetReceiver.push
           ├─ WidgetResizeCore.bump               世代号
           ├─ TodayWidgetReceiver.loadDataSync    runBlocking 读 Room
           ├─ WidgetBitmapRenderers.renderToday   Canvas 画 bitmap
           └─ RemoteViewsWidgetHelper.renderAndPush
               ├─ computeSizeDp                  OPTION_APPWIDGET_SIZES → 真实尺寸
               ├─ setImageViewBitmap             bitmap 进 RemoteViews.mBitmapCache
               ├─ setOnClickPendingIntent        点透 app
               ├─ WidgetResizeCore.isStale       校验世代号
               └─ awm.updateAppWidget            binder 提交 (不 recycle bitmap)

内容超出 → pushScrollable → 壳图 + setRemoteAdapter(widget_strip_list, ScrollStripService intent) → notifyAppWidgetViewDataChanged → launcher ListView 触发 onDataSetChanged → StripFactory 调原渲染器画全展开长图 → 单 child 整图 + 显式 dp 行高。

相关页面

Clone this wiki locally