-
Notifications
You must be signed in to change notification settings - Fork 10
Widget Rendering
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 查询。
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 一并移除。
桌面组件按当前 cell 真实尺寸渲染 bitmap。bitmap 宽高与容器宽高一致时 fitXY 等价于 1:1 像素映射,无拉伸、无黑边、无畸变。app/src/main/res/layout/widget_bitmap_container.xml:5 注释直接点明这条契约:android:scaleType="fitXY" + android:adjustViewBounds="false"。
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"))。
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。
渲染流程固定三步:
- 按 density 算像素宽高 →
Bitmap.createBitmap(w, h, ARGB_8888) - 拿 scheme(见配色一节)
- 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 → 紧凑档(纯文本,无课程胶囊);否则升档回全量排版。
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 一起回收。
内容超出容器高度时启用可滚动分支。结构是壳图 + 条带 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。
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 的结果。
所有桌面组件渲染共用一个取色入口,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°)。
- 常量层:
GOLDEN_ANGLE / SENTINEL_COLOR / S_LIGHT(0.55) / S_DARK(0.40) / L_LIGHT(0.82) / L_DARK(0.28) - 纯逻辑层:
stableHue(groupId)用groupId.hashCode()(课程身份标识)算 hue,不能换成course.id(数据库自增主键,随导入漂移) - 平台适配层:
pickCourseColorCompose/pickCourseColorInt两套返回类型,同一份逻辑
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 行高。
Sleepy Wiki
入门
界面与视图
课程管理
导入导出
小组件与提醒
数据与设置
项目
社区