Super Productivity iOS 桌面小组件移植:WidgetKit 复用 `v:1` 快照契约的架构与实现指南
2026/9/13 4:08:32 网站建设 项目流程

Super Productivity iOS 桌面小组件移植:WidgetKit 复用v:1快照契约的架构与实现指南

【免费下载链接】super-productivitySuper Productivity is an advanced todo list app with integrated Timeboxing and time tracking capabilities. It also comes with integrations for Jira, GitLab, GitHub and Open Project.项目地址: https://gitcode.com/GitHub_Trending/su/super-productivity

导读:本文基于仓库中的规划文档 docs/plans/2026-07-07-ios-home-screen-widget-port.md,完整还原 Super Productivity 将 Android 桌面任务小组件移植到 iOS WidgetKit 的工程方案。文章覆盖可复用的v:1JSON 快照契约、单一写入者不变式、last-wins 完成态队列、.never时间线策略、跨端(Xcode 工程 / Swift 扩展 / Capacitor 桥接 / Angular 泛化)四个工作项与 CI 签名改造,并对照仓库中的 Android 实现源码(android-widget.model.ts、WidgetData.kt、WidgetDoneQueue.kt 等)逐层验证契约、队列与排水逻辑。读完你将掌握:如何在不让原生端复刻任何日程规则的前提下,用"单向版本化快照 + last-wins 点击队列 + 渲染期待定覆盖"模式把 Angular 状态可靠投影到两个平台的桌面小组件上,以及 iOS 移植中的边界取舍。

一、移植背景:为什么这是一次"视图层 + 管道"的搬运而非重新设计

Super Productivity 是一款内置番茄钟/时间追踪与 Jira、GitLab、GitHub、Open Project 集成的任务管理应用。其 Android 端通过 PR #8737 引入了桌面任务小组件,并沉淀出一份被维护的正式指南 docs/android-home-screen-widget.md。iOS 侧规划文档的目标,是把这套 Android 小组件按同一套契约移植到 iOS 的 WidgetKit 扩展上。

规划文档开宗明义地指出:Android 架构本身——单向版本化 JSON 快照(one-way versioned JSON snapshot)+ last-wins 完成态点击队列 + 渲染期待定覆盖(render-time pending overlay)——恰好就是 WidgetKit 期望的形态。因此这次移植不是重新设计,而是一次纯粹的"视图层 + 管道(plumbing)"搬运:数据契约不变、写入者不变、队列语义不变,只换掉展示端和传输通道。

理解这一点是读懂整篇规划的前提:后续所有小节都是围绕"如何在不破坏 Android 已验证不变式的前提下,为 iOS 换一套原生端"展开的。

二、架构映射:v: 1契约原封不动地跨平台复用

规划文档用一张对照表精确刻画了 Android 与 iOS 的逐项映射。下表完整保留该映射(并标注了仓库中对应源码):

AndroidiOS(WidgetKit 移植目标)
KeyValStorewidget_data快照(SQLite)App Group 的UserDefaults(suiteName:)相同的 key、相同的 JSON
TaskListWidgetProvider+RemoteViewsService+ XML 布局WidgetKit 扩展:TimelineProvider+ SwiftUI 列表
JavaScriptInterface.saveToDbWrapped/updateWidget()本地 Capacitor 插件:setWidgetData(json)+WidgetCenter.shared.reloadTimelines
复选框点击 →WidgetDoneQueue(SharedPreferences)Button(intent:)→ AppIntent 把同样的{taskId: targetIsDone}映射写入 App Group defaults
渲染期待定覆盖(WidgetData.parse(pendingDoneTargets:)Swift 解析器中的同款覆盖逻辑——逐行移植,含 JSON-null 守卫
排水触发:onResume$+ 实时 LocalBroadcast仅 Capacitorresume(见"已知限制")
Header/行点击 → 启动 activitywidgetURL深链 → 打开 App(无单任务导航,与 Android v1 一致)

从这张表可以提炼出移植中必须原样继承的三条不变式

  1. 单一写入者不变式(single-writer invariant):Angular 是widget_data的唯一写入者;AppIntent 只写队列;小组件在渲染时叠加待定目标。因此写入竞争在结构上不可能发生。这一点在 Android 源码中有明确注释背书——android-widget.model.ts 声明 "Angular is the ONLY writer of this blob. Pending widget done-taps are overlaid natively at render time from WidgetDoneQueue, never written into the blob."
  2. 时间线策略.never:条目永不过期,每次刷新都是一次显式的reloadTimelines推送(App 快照写入后、AppIntent 队列写入后各推一次)。无轮询、不消耗后台刷新预算。
  3. 契约一致性:Swift 解析器成为该v: 1blob 的第三个命名端(Angular 序列化端、Kotlin 解析端之后);未知v渲染空小组件,与 Kotlin 行为一致。

三、v:1快照契约详解:边界判断只在一个语言里发生

3.1 TypeScript 端的 blob 形状

TypeScript 契约定义在 src/app/features/android/android-widget.model.ts:

export const ANDROID_WIDGET_DATA_KEY = 'widget_data'; export interface AndroidWidgetTask { id: string; title: string; isDone: boolean; // 无项目时省略(而非 null)——org.json 的 optString 会把 JSON null 映射成字符串 "null" projectId?: string; } export interface AndroidWidgetData { v: 1; /** DISPLAY ONLY —— 快照面向的逻辑日(YYYY-MM-DD),仅在快照过期后用于页眉展示 */ dayStr: string; /** THE VERDICT —— 快照失效的 epoch 毫秒时刻;原生端唯一的新鲜度判断就是 now >= validUntil */ validUntil: number; tasks: AndroidWidgetTask[]; projectColors: { [projectId: string]: string }; }

这份模型注释里埋着整个跨端设计的核心哲学:App 端"出货判决结果,而不是判决依据"(ships the decision rather than its inputs)。原生端永远不需要复刻getDbDateStr的时区/夏令时语义、重复任务物化(repeat instance materialization)、逾期结转(overdue carry-over)或虚拟TODAY_TAG归属——因为进程死亡时这些逻辑本来就没跑过,快照就是"昨天的",小组件唯一诚实的做法是如实展示。

规划文档特别点出一个边界权衡:validUntil冻结的是写入时刻设备所在的时区。向西飞(时区回拨)快照会提前过期——"过期"误报,是安全失败方向;向东飞则延迟过期,短暂复现 #9098。而且设备时区不是 selector 输入,所以落地后单独推一次不会触发重算(selector 被 memoize 了)——只有输入真正变化(今天的任务 id、某个任务/项目、todayStr 或偏移量)或重启时才会重算。

3.2 边界时刻由谁、如何算出

validUntil由 selector src/app/features/android/store/android-widget.selectors.ts 中的getWidgetValidUntil计算:

export const getWidgetValidUntil = ( dayStr: string, startOfNextDayDiffMs: number, ): number => { const [year, month, day] = dayStr.split('-').map(Number); return new Date(year, month - 1, day + 1).getTime() + startOfNextDayDiffMs; };

new Date(y, m, d)会自动规范化月份/年份溢出,并落在本地午夜上——这保证了跨夏令时边界时不会像朴素+24h那样漂移一小时(柏林与洛杉矶两个时区下均有测试覆盖)。selector 故意不含Date.now(),保持纯函数,从而保持 replay 确定性。

selectAndroidWidgetData将"今日任务 id → 任务实体 → 项目主题色"投影为 blob 形状;无projectId的任务不写入projectColors。规划中同步强调:作为 Angular 步骤的一部分,TypeScript 类型要从平台相关的AndroidWidgetData重命名为平台无关的WidgetData(文件也从features/android/android-widget.*迁到features/widget/)。

3.3 Kotlin 解析端的边界守卫(Swift 逐行移植的模板)

Android 原生解析端 WidgetData.kt 定义了 Swift 端必须逐行复刻的边角情况:

  • 版本门控root.optInt("v", -1) != SUPPORTED_VERSION直接返回空列表,未知版本失败关闭(fail closed);
  • projectId缺失处理:Kotlin 用isNull守卫而非optString——因为 Android 的optString会把 JSON null 映射成字符串字面量"null";Angular 侧惯例是省略(omit)而非置空,两者必须对应;
  • projectColors查找takeIf { !it.isNull(pId) }后再取值;
  • parseMeta的独立退化dayStrvalidUntil各自独立退化为 null 而非"看起来合理的默认值"——optLong的默认0L是 1970 年这个真实时刻,会误判为"早已过期";缺失必须是"未知";
  • dayStrToMs严格解析SimpleDateFormat默认宽松,"2026-02-30"会被滚进 3 月,"2026-07-17garbage"会被前缀解析——因此显式isLenient = false且用Locale.US钉住公历与 ASCII 数字(设备默认日历可能是佛历或波斯历)。

一个值得注意的设计:parseMeta与任务列表分离加载(header 在 provider 的 RemoteViews 里,列表在RemoteViewsFactory),而过期判定isSnapshotStale只在有validUntil时成立——没有可用时间戳时宁可信任列表(它在下次推送时自愈),也不许无依据地宣告过期。headerFor决策被做成 Context-free 纯函数以便单测,避免把决策留在 provider 里导致"倒过来渲染且测试全绿"。

规划文档为 Swift 侧提出的要求与此完全对齐:版本门控 → 空列表;缺失projectId(Angular 省略而非 null);projectColors查找;以及同款 JSON-null 守卫。同时要求用与WidgetDataTest.kt相同的 golden JSON fixture 为两个解析器加单测——复制同一份 fixture,把两端锁死在同一个形状上。

四、last-wins 完成态队列:点按在原生端,排水在 Angular

4.1 Android 的队列实现

WidgetDoneQueue.kt 用 SharedPreferences 保存一个 JSON 对象映射{taskId: targetIsDone}

@Synchronized fun setTarget(context: Context, taskId: String, isDone: Boolean) { val prefs = getPrefs(context) val map = prefs.getString(KEY_DONE_TASKS, null)?.let { try { JSONObject(it) } catch (e: Exception) { JSONObject() } } ?: JSONObject() map.put(taskId, isDone) // 用 commit 而非 apply:入队发生在短生命周期广播中,进程可能随即被杀——点按必须存活 prefs.edit().putString(KEY_DONE_TASKS, map.toString()).commit() }

三个关键语义:

  • last-wins:同一任务反复点按(完成→取消)在 App 运行前坍缩为单次变更甚至空操作(no-op);
  • peek()只读不清:供渲染期叠加待定状态,原生端永远不改写快照 blob;
  • getAndClear()读后即清@Synchronized保证 get-and-clear 原子性;用commit(同步落盘)而非apply,因为入队发生在短生命周期广播里,进程可能随后被杀。

4.2 iOS 的对应物

规划文档给出 Swift 侧设计:

  • DoneQueue.swift:App Group defaults 中的 last-wins[String: Bool]setTarget/getAndClear/peek镜像WidgetDoneQueue.kt语义(用串行队列保证 get-and-clear 原子性;UserDefaults 对单槽 JSON 字符串足够进程安全,对应 SharedPreferences 的做法);
  • ToggleDoneIntent(AppIntent):参数taskId+setDone——目标值在渲染时由"当前显示状态"算出,所以重复点按会来回切换(与 Android punch-list 第 1 项是精神上的同源修复)。写入队列后返回,WidgetKit 在 intent 后自动重渲染,覆盖层立即呈现新状态;
  • 渲染侧TaskListWidget.swift的 SwiftUI 列表复刻 Kotlin 解析器的 pending-done 覆盖:isDone = pendingDoneTargets[id] ?: task.optBoolean("isDone", false)

4.3 Angular 端的纯排水逻辑(两个平台共用)

队列的消费端在 src/app/features/android/store/android-widget.effects.ts,核心是导出供直接测试的纯函数:

export const getTaskDoneChangesToApply = ( queueJson: string, taskEntities: Dictionary<Task>, ): { id: string; isDone: boolean }[] => { let targets: unknown; try { targets = JSON.parse(queueJson); } catch (e) { DroidLog.err(...); return []; } if (typeof targets !== 'object' || targets === null || Array.isArray(targets)) return []; return Object.entries(targets as Record<string, unknown>) .filter(([id, isDone]) => { const task = taskEntities[id]; return typeof isDone === 'boolean' && !!task && task.isDone !== isDone; }) .map(([id, isDone]) => ({ id, isDone: isDone as boolean })); };

去重 + 跳过已在目标态:被删任务(点按后已删除)与已处于目标态的任务被过滤掉,过期的队列条目永远不会产生冗余 update op。Android 的排水链路是onResume$ReplaySubject(1),冷启动与后台→前台都能覆盖)合并onWidgetDoneDrainRequest$(App 存活时来自原生的"无内容排水信号"),再用concatMap等待isAllDataLoadedInitially$与任务实体就绪后统一排水,最后弹出聚合提示T.F.ANDROID.WIDGET_TASKS_UPDATED(该 key 已在 en.json 中:"{{count}} task(s) updated from widget",且已在全部语言包中翻译)。

规划文档明确:iOS 不写任何 iOS 专属排水逻辑,直接复用这一纯getTaskDoneChangesToApply();排水触发改用 Capacitorresume+ 初始数据加载门控(initial-data-loaded gate)。

五、四个工作项:从 Xcode 工程到 Angular 泛化的完整落地清单

5.1 工作项 1:Xcode 工程 + 签名("摩擦的一半",无逻辑)

  • 新建 WidgetKit 扩展 targetSupWidget,bundle IDcom.super-productivity.app.widget部署目标 iOS 17.0(App 本体仍为 16.0)。理由:交互式小组件(Button(intent:)/AppIntents)要求 iOS 17+;为 16 提供"只能看不许点"的降级意味着第二条代码路径和更差的小组件——因此 17 以下直接不提供小组件,App 本身不受影响。仅当 16.x 采用数据另有说法时才重议;
  • 两个 target 都要开 App Groups capability,group ID 建议group.com.super-productivity.app
  • Apple developer portal:注册扩展 App ID,在两个 App ID 上都启用 App Group,重新生成两份 provisioning profile;
  • CI(.github/workflows/build-ios.yml):当前签名使用单个手工管理的 profile secret(IOS_PROVISION_PROFILE);需要为扩展 profile 增加第二个 secret,以相同方式安装,并在 export options 中加对应条目。现有 "Apple Distribution" 证书可同时覆盖两个 target;
  • npx cap sync ios不得与新增 target 冲突——扩展 target 位于 Capacitor 托管组之外,需验证一次并在扩展目录 README 中记录。

5.2 工作项 2:Widget 扩展(Swift,约 250–400 行,全新建)

四个文件分工:

  • WidgetData.swift:解析v: 1JSON + pending-done 覆盖,逐行移植 Kotlin 解析器边角(见第三节);在扩展 target 内用与WidgetDataTest.kt相同的 golden JSON 做单测;
  • DoneQueue.swift:见 4.2;
  • ToggleDoneIntent(AppIntent):见 4.2;
  • TaskListWidget.swiftTimelineProvider(单条目、.never)+ SwiftUI 视图——header(App 名 + 数量,点击 =widgetURL)、任务行(项目色条、标题、Button(intent:)复选框)、空态。v1 提供.systemMedium+.systemLarge两种 family。静态偏深色样式对齐 Android v1 观感;仅在"免费"时才跟随系统colorScheme

5.3 工作项 3:桥接插件(Swift + ObjC stub,约 100 行)

本地 Capacitor 插件WidgetBridgePlugin放在ios/App/App/,沿用现有StoreReviewPlugin.swift/.m模式(当前仓库中该模式的两个实体为 StoreReviewPlugin.swift 与 StoreReviewPlugin.m):

  • setWidgetData({ json })→ 写入 App Group defaults,然后WidgetCenter.shared.reloadTimelines(ofKind:)
  • getAndClearDoneQueue()→ 返回{ json: string | null }

不实现getWidgetTaskQueue对应物——分享 intent 处理不在本次范围内。

5.4 工作项 4:Angular 泛化(约 100–150 行,多为泛化改造)

  • WidgetDataService中抽出平台特定写入端:保留 selector 读取 + 上次推送 JSON 去重,分流 sink——IS_ANDROID_WEB_VIEWandroidInterfaceCapacitor.getPlatform() === 'ios'registerPlugin<WidgetBridgePlugin>('WidgetBridge')。插件注册模式参考 src/app/features/dialog-please-rate/store-review/index.ts;
  • Effects:通过放宽门控("android webview OR iOS native")复用 android-widget.effects.ts 的既有触发器。iOS 触发点:状态变化(防抖 + 既有 hydration 守卫)、同步窗口下降沿、Capacitorpause(App Group 写入很快,适配约 5 秒后台宽限期);
  • 目录迁移:features/android/android-widget.*features/widget/平台中立命名;android-interface.ts保留作为 Android sink 的角色;复用同一条WIDGET_TASKS_UPDATEDsnack(已存在于语言包);
  • 同步正确性:风险画像不变——effects 保持dispatch: false的状态消费者;排水路径产生与 Android 完全一致的用户意图操作(去重 + 跳过已在目标态,防止重放噪声);同步窗口内没有任何新写入。

六、排水链路与推送触发器的源码级对照

为了让你在实现时能对照现有代码,下表把 Android 侧的触发/排水机制与其 iOS 对应方案并排列出(依据规划文档与 android-widget.effects.ts、android-interface.ts):

触发器Android 实现iOS 移植方案
状态变化推送select(selectAndroidWidgetData)→ 500ms 防抖 →pushCurrent(),带 hydration 守卫同一 selector 门控放宽为 android/iOS 双平台
同步窗口关闭推送isInSyncWindow$pairwise 下降沿 →pushCurrent()相同(dispatch: false状态消费者)
后台前最后一推androidInterface.onPause$pushCurrent()(不防抖)CapacitorpausepushCurrent()
完成态排水onResume$+onWidgetDoneDrainRequest$→ 等数据就绪 →getTaskDoneChangesToApplyresume+ initial-data-loaded 门控,同一纯函数

WidgetDataService.pushCurrent()(widget-data.service.ts)的去重细节值得在移植时保留:比较整个 blob(不只是 tasks)——日切换时列表往往逐字节相同,只有失效时间戳在移动,而那一次推送恰恰是让小组件"不再过期"的唯一动作;收窄比较键会静默复现 #9098。

七、已知限制:与 Android v1 持平或更低(刻意为之)

  • App 存活期间无实时排水:Android 用 LocalBroadcast 戳一下运行中的 WebView;iOS 从扩展进程没有廉价的等价物(Darwin 通知 = v1 的过度设计)。App 在前台时的点按要等下一次resume才生效。缓解手段是 pending 覆盖:小组件本身始终即时正确。如果将来有必要:CFNotificationCenterDarwin 通知是升级路径;
  • 陈旧直到下次打开(stale-until-next-open):与 Android 死进程时相同,但因 iOS 会激进挂起 WebView 而更频繁。跨午夜翻日时小组件显示昨天的列表,直到下次打开 App。挂起期间的跨端新鲜度留在 phase 2(BGAppRefreshTask + sync——与 Android 的 WorkManager 构想同一 phase-2 槽位);
  • 仅 iOS 17+(App 本体保持 iOS 16);
  • 小组件 chrome 文案仅英文(通过扩展的 strings 文件;与 Android v1strings.xml对齐);
  • 无任务创建 / 撤销 / 单任务深链(与 Android v1 一致)。

八、开放决策与工作量评估

开放决策(实现前必须先定)

  1. App Group ID 字符串:建议group.com.super-productivity.app;上线后难以更改(旧容器中的陈旧数据会被遗弃),必须一次定死;
  2. TS 重命名(features/android/android-widget.*features/widget/是作为准备性重构 PR 落地,还是并入功能 PR 内。准备性 PR 便于评审;无论如何 Android widget PR #8737 必须先合并,避免在重命名之上做 rebase。

工作量评估:约 2–4 个专注工作日——Xcode target/App Group/portal/CI 签名约 0.5–1 天;扩展 + 插件约 1–1.5 天;Angular 泛化 + spec 约 0.5 天;其余时间用于真机测试(交互式小组件在模拟器上不稳定;需要 Mac + 真机)。App Store 评审对小组件是例行流程。

九、交付文件清单(规划中的目标产物)

原生端(除注明外全新建)ios/App/SupWidget/{TaskListWidget,WidgetData,DoneQueue,ToggleDoneIntent}.swift;扩展Info.plist+ entitlements;App/App.entitlements(App Group,编辑);ios/App/App/WidgetBridgePlugin.swift+.mproject.pbxproj(新 target);widget 单元测试 + 共享 golden JSON fixture。

Angularfeatures/widget/widget-data.model.tsfeatures/widget/widget-data.service.ts(+spec);features/widget/store/widget.selectors.ts(+spec);features/widget/store/widget.effects.ts(+spec);features/widget/widget-bridge.ts(CapacitorregisterPlugin);root-store/feature-stores.module.ts

CI/发布.github/workflows/build-ios.yml(扩展 profile);新 secretIOS_WIDGET_PROVISION_PROFILE;export options。

十、移植守则:实现时必须守住的五条不变量

综合 Android 维护指南 docs/android-home-screen-widget.md 与 iOS 规划文档,本次移植必须保持:

  1. 单一写入者快照:只有 Angular 写widget_data;原生只写队列;渲染期叠加待定目标,永不改写 blob;
  2. 队列化意图投递:点按不直接改 App 状态,进 last-wins 队列,App 侧去重后应用;get-and-clear 保证至少一次语义下不重放;
  3. 逻辑日边界:原生只认validUntil这唯一判决,不复刻任何日程规则(dayStr 仅作展示标签);
  4. 同步后刷新:同步窗口关闭后必须显式推送,否则hydration守卫会丢掉所有窗口内发射;
  5. 双端 golden 锁定:任何契约变更必须同步更新 TS 序列化端、Kotlin 解析端、Swift 解析端及两端测试,三者锁死同一形状。

这套模式的价值在于:它让"桌面小组件"成为纯原生投影(native projection)——不是独立的任务或日历引擎,而是 Angular 状态的只读视图加上一个受控的完成态回写通道。Android 已验证其正确性,iOS 移植的全部工作就是为同一契约补齐第二个原生端。

【免费下载链接】super-productivitySuper Productivity is an advanced todo list app with integrated Timeboxing and time tracking capabilities. It also comes with integrations for Jira, GitLab, GitHub and Open Project.项目地址: https://gitcode.com/GitHub_Trending/su/super-productivity

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询