小组件是本项目最复杂的部分,因为 Android 对它有大量限制。这里记录设计与踩过的坑。
- 顶栏:最近有课的日历名 + 日期 + 教学周次
- 课表:今天 / 明天 双栏,各含彩色竖条、课程名、地点、时间
- 待办:复选框 + 标题 + 截止日期,可点按完成,已完成的显示划线并沉底
- 三个区域各自可上下滑动
- 圆角磨砂外观,跟随系统日夜模式自动切换,也可在 App 里自定义配色与背景图
- 上过的课自动消失(按结束时间过滤,无需打开 App)
初版把内容渲染成一张静态位图塞进 ImageView。位图在桌面上无法滚动 —— 这是硬限制,不是实现问题。
要实现滑动必须改用 RemoteViewsService + ListView,即「集合型小组件」。
小组件运行在独立进程,读不到 WebView 的 IndexedDB,也没有网络可拉。所以由 App 主动推送快照:
App(IndexedDB)
↓ buildWidgetSnapshot() 整理成今日数据
↓ ITDCWidgetPlugin.pushSnapshot()
原生侧 SharedPreferences(widget_prefs)
↓ onDataSetChanged()
RemoteViewsService 渲染列表
推送时机:App 启动、任意数据变更、回到前台、切换数据模式。
小组件改不了数据库,所以采用「桌面先生效、App 后回写」:
点按复选框
↓ 立即写入原生队列(widget_done)
桌面立刻显示划线(零延迟)
↓ App 启动 / 回到前台
getDoneQueue() → 写回 IndexedDB → clearDoneQueue()
用 pending_done / pending_undone 两个集合实现互斥,这样点错了可以再点一次取消。
回写失败时保留队列,下次重试 —— 避免桌面状态与数据库永久不一致。
面板底色、不透明度、明暗、强调色与背景图都由 App 下发,走的是与快照同一条通道:
设置 → 外观(src/components/AppearanceSettings.tsx)
↓ pushWidgetAppearance() src/api/appearance.ts
↓ ITDCWidgetPlugin.setAppearance()
WidgetAppearance.java → SharedPreferences(widget_prefs) + files/widget_bg.jpg
↓ requestRefresh()
RemoteViews 重新渲染
几个设计上的取舍:
- 背景走位图,不走 drawable。
RemoteViews无法承载运行时构造的 drawable, 资源色(@color/widget_bg)也不能在运行时改。因此「圆角 + 底色/照片 + 透明度」 由WidgetAppearance.backgroundBitmap()一次性画成位图交给ImageView。 根布局自带的圆角底色必须同时置为透明,否则会在位图透明处透出第二层颜色。 - 位图有硬预算。Binder 事务上限约 1MB,超了部分桌面会抛
TransactionTooLargeException,表现为整块小组件空白。因此按小组件实际像素缩到 160k 像素(不透明,RGB_565)或 80k 像素(半透明,ARGB_8888)以内。 纯色面板不需要高分辨率,只保留够画圆角的像素。 - 图片只在换图时传。base64 几百 KB,滑动条之类的微调不必重复传,原生侧沿用已存的文件。
- 复选框拆成三层。空心框 / 实心框 / 对勾各是一张白色矢量,颜色由
setColorFilter决定(实心框染主题色、空心框染次要文字色、对勾保持白色)。 一开始是自绘位图,但位图在集合型子项里不可靠 —— 见下面第 7 条坑。
这些限制都不是文档里显眼写着的,是实测撞出来的。改动小组件时请留意:
用 <View> 画分隔线会抛:
Class not allowed to be inflated android.view.View
整个小组件会显示成 Can't load widget。分隔线和色条必须用 ImageView。
对 ListView 的子项调用 setOnClickPendingIntent 会被静默忽略,点击毫无反应。
必须 setPendingIntentTemplate + 子项 setOnClickFillInIntent,且模板 PendingIntent 必须是
FLAG_MUTABLE —— 用 FLAG_IMMUTABLE 会导致 fill-in 的 extras 被丢掉,接收到的 id 永远是 -1。
Provider 声明了 android:permission="BIND_APPWIDGET",广播要求发送方持有该权限,
而 App 自己并不持有 —— 广播被系统静默丢弃,表现为「数据写进去了但桌面不更新」。
正确做法是直接调用刷新方法。
时间/日期变化的广播同样受影响,因此单独用了一个不带该权限的 receiver
(ITDCWidgetTimeChangeReceiver)。
Service Worker 缓存会导致装了新 APK 却跑旧代码,小组件因此收到旧格式数据。
npm run build:native 会把 SW 替换成自我清理脚本(见 scripts/native-sw-killswitch.mjs)。
setImageViewBitmap 的位图会进 Binder 事务,超过约 1MB 就抛
TransactionTooLargeException,而且错误发生在桌面进程,App 侧看不到堆栈,
桌面只表现为小组件空白。生成位图前必须先按目标尺寸缩放到安全预算内。
实测(模拟器 AOSP 启动器 + 真机澎湃 OS 都复现):在集合型子项的 getViewAt 里用
setImageViewBitmap 设置图片,首次渲染正常,但之后由 notifyAppWidgetViewDataChanged
触发的重绘,启动器会跳过这个位图动作,同一批动作里的 setPaintFlags(划线)、
setTextColor、setViewVisibility 却都会正常应用。
症状很迷惑人:桌面点勾后划线和排序立刻更新,但方框里没有勾,打开 App 触发一次完整
updateAppWidget 才补上。
结论:集合型子项里用资源 + 属性类动作表达状态,别用位图:
rv.setViewVisibility(R.id.todo_check_fill, done ? View.VISIBLE : View.GONE);
rv.setInt(R.id.todo_check_fill, "setColorFilter", accent); // 颜色照样能自定义主布局(非集合)上的位图不受影响 —— 小组件的照片背景就是这么渲染的。
Android 15+ 抬高了 setInexactRepeating 的最短周期,Doze 下还会跳过;澎湃 OS 更是会冻结后台。
因此定时只作兜底,主要依赖三个不依赖后台存活的时机:
- App 推送快照时主动触发
appwidget-provider的updatePeriodMillis(系统托管)- App 回到前台时补推
代价:「上过的课自动消失」最多滞后约 30 分钟(系统最短周期)。刚下课那几分钟它可能还挂着。
澎湃 OS / MIUI 默认会限制后台。如果小组件长时间不刷新:
设置 → 应用 → ITDC → 省电策略 → 无限制
App 的设置页会检测电池优化状态并给出跳转入口。
| 文件 | 作用 |
|---|---|
ITDCWidgetProvider.java |
小组件主体:刷新、渲染、列表绑定 |
ITDCWidgetListService.java |
三个列表的数据源与条目构建 |
ITDCWidgetActionReceiver.java |
点按完成 / 全部完成的接收与队列写入 |
ITDCWidgetTimeChangeReceiver.java |
时间或日期变化时重绘 |
WidgetDoneStore.java |
完成状态的本地存储与回传队列 |
WidgetAppearance.java |
主题色 / 面板底色与透明度 / 明暗 / 背景图的存储与位图生成 |
ITDCWidgetPlugin.java |
暴露给 JS 的桥接接口 |
res/layout/widget_main.xml |
主布局 |
res/values-night/colors.xml |
默认深色配色(用户自定义后由 WidgetAppearance 覆盖) |