资讯动态

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

发布时间:2026/9/13 4:08:35 来源:尧图企业网站定制
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 的逐项映射。下表完整保留该映射并标注了仓库中对应源码AndroidiOSWidgetKit 移植目标KeyValStore的widget_data快照SQLiteApp Group 的UserDefaults(suiteName:)相同的 key、相同的 JSONTaskListWidgetProviderRemoteViewsService XML 布局WidgetKit 扩展TimelineProvider SwiftUI 列表JavaScriptInterface.saveToDbWrapped/updateWidget()本地 Capacitor 插件setWidgetData(json)WidgetCenter.shared.reloadTimelines复选框点击 →WidgetDoneQueueSharedPreferencesButton(intent:)→ AppIntent 把同样的{taskId: targetIsDone}映射写入 App Group defaults渲染期待定覆盖WidgetData.parse(pendingDoneTargets:)Swift 解析器中的同款覆盖逻辑——逐行移植含 JSON-null 守卫排水触发onResume$ 实时 LocalBroadcast仅 Capacitorresume见已知限制Header/行点击 → 启动 activitywidgetURL深链 → 打开 App无单任务导航与 Android v1 一致从这张表可以提炼出移植中必须原样继承的三条不变式单一写入者不变式single-writer invariantAngular 是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.时间线策略.never条目永不过期每次刷新都是一次显式的reloadTimelines推送App 快照写入后、AppIntent 队列写入后各推一次。无轮询、不消耗后台刷新预算。契约一致性Swift 解析器成为该v: 1blob 的第三个命名端Angular 序列化端、Kotlin 解析端之后未知v渲染空小组件与 Kotlin 行为一致。三、v:1快照契约详解边界判断只在一个语言里发生3.1 TypeScript 端的 blob 形状TypeScript 契约定义在 src/app/features/android/android-widget.model.tsexport 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 closedprojectId缺失处理Kotlin 用isNull守卫而非optString——因为 Android 的optString会把 JSON null 映射成字符串字面量nullAngular 侧惯例是省略omit而非置空两者必须对应projectColors查找takeIf { !it.isNull(pId) }后再取值parseMeta的独立退化dayStr与validUntil各自独立退化为 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 侧提出的要求与此完全对齐版本门控 → 空列表缺失projectIdAngular 省略而非 nullprojectColors查找以及同款 JSON-null 守卫。同时要求用与WidgetDataTest.kt相同的 golden JSON fixture 为两个解析器加单测——复制同一份 fixture把两端锁死在同一个形状上。四、last-wins 完成态队列点按在原生端排水在 Angular4.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-oppeek()只读不清供渲染期叠加待定状态原生端永远不改写快照 blobgetAndClear()读后即清Synchronized保证 get-and-clear 原子性用commit同步落盘而非apply因为入队发生在短生命周期广播里进程可能随后被杀。4.2 iOS 的对应物规划文档给出 Swift 侧设计DoneQueue.swiftApp Group defaults 中的 last-wins[String: Bool]setTarget/getAndClear/peek镜像WidgetDoneQueue.kt语义用串行队列保证 get-and-clear 原子性UserDefaults 对单槽 JSON 字符串足够进程安全对应 SharedPreferences 的做法ToggleDoneIntentAppIntent参数taskIdsetDone——目标值在渲染时由当前显示状态算出所以重复点按会来回切换与 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: DictionaryTask, ): { 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 Recordstring, 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 工作项 1Xcode 工程 签名摩擦的一半无逻辑新建 WidgetKit 扩展 targetSupWidgetbundle IDcom.super-productivity.app.widget部署目标 iOS 17.0App 本体仍为 16.0。理由交互式小组件Button(intent:)/AppIntents要求 iOS 17为 16 提供只能看不许点的降级意味着第二条代码路径和更差的小组件——因此 17 以下直接不提供小组件App 本身不受影响。仅当 16.x 采用数据另有说法时才重议两个 target 都要开 App Groups capabilitygroup ID 建议group.com.super-productivity.appApple developer portal注册扩展 App ID在两个 App ID 上都启用 App Group重新生成两份 provisioning profileCI.github/workflows/build-ios.yml当前签名使用单个手工管理的 profile secretIOS_PROVISION_PROFILE需要为扩展 profile 增加第二个 secret以相同方式安装并在 export options 中加对应条目。现有 Apple Distribution 证书可同时覆盖两个 targetnpx cap sync ios不得与新增 target 冲突——扩展 target 位于 Capacitor 托管组之外需验证一次并在扩展目录 README 中记录。5.2 工作项 2Widget 扩展Swift约 250–400 行全新建四个文件分工WidgetData.swift解析v: 1JSON pending-done 覆盖逐行移植 Kotlin 解析器边角见第三节在扩展 target 内用与WidgetDataTest.kt相同的 golden JSON 做单测DoneQueue.swift见 4.2ToggleDoneIntentAppIntent见 4.2TaskListWidget.swiftTimelineProvider单条目、.never SwiftUI 视图——headerApp 名 数量点击 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.msetWidgetData({ json })→ 写入 App Group defaults然后WidgetCenter.shared.reloadTimelines(ofKind:)getAndClearDoneQueue()→ 返回{ json: string | null }。不实现getWidgetTaskQueue对应物——分享 intent 处理不在本次范围内。5.4 工作项 4Angular 泛化约 100–150 行多为泛化改造从WidgetDataService中抽出平台特定写入端保留 selector 读取 上次推送 JSON 去重分流 sink——IS_ANDROID_WEB_VIEW→androidInterfaceCapacitor.getPlatform() ios→registerPluginWidgetBridgePlugin(WidgetBridge)。插件注册模式参考 src/app/features/dialog-please-rate/store-review/index.tsEffects通过放宽门控android webview OR iOS native复用 android-widget.effects.ts 的既有触发器。iOS 触发点状态变化防抖 既有 hydration 守卫、同步窗口下降沿、CapacitorpauseApp 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()不防抖Capacitorpause→pushCurrent()完成态排水onResume$onWidgetDoneDrainRequest$→ 等数据就绪 →getTaskDoneChangesToApply仅resume initial-data-loaded 门控同一纯函数WidgetDataService.pushCurrent()widget-data.service.ts的去重细节值得在移植时保留比较整个 blob不只是 tasks——日切换时列表往往逐字节相同只有失效时间戳在移动而那一次推送恰恰是让小组件不再过期的唯一动作收窄比较键会静默复现 #9098。七、已知限制与 Android v1 持平或更低刻意为之App 存活期间无实时排水Android 用 LocalBroadcast 戳一下运行中的 WebViewiOS 从扩展进程没有廉价的等价物Darwin 通知 v1 的过度设计。App 在前台时的点按要等下一次resume才生效。缓解手段是 pending 覆盖小组件本身始终即时正确。如果将来有必要CFNotificationCenterDarwin 通知是升级路径陈旧直到下次打开stale-until-next-open与 Android 死进程时相同但因 iOS 会激进挂起 WebView 而更频繁。跨午夜翻日时小组件显示昨天的列表直到下次打开 App。挂起期间的跨端新鲜度留在 phase 2BGAppRefreshTask sync——与 Android 的 WorkManager 构想同一 phase-2 槽位仅 iOS 17App 本体保持 iOS 16小组件 chrome 文案仅英文通过扩展的 strings 文件与 Android v1strings.xml对齐无任务创建 / 撤销 / 单任务深链与 Android v1 一致。八、开放决策与工作量评估开放决策实现前必须先定App Group ID 字符串建议group.com.super-productivity.app上线后难以更改旧容器中的陈旧数据会被遗弃必须一次定死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 entitlementsApp/App.entitlementsApp Group编辑ios/App/App/WidgetBridgePlugin.swift.mproject.pbxproj新 targetwidget 单元测试 共享 golden JSON fixture。Angularfeatures/widget/widget-data.model.tsfeatures/widget/widget-data.service.tsspecfeatures/widget/store/widget.selectors.tsspecfeatures/widget/store/widget.effects.tsspecfeatures/widget/widget-bridge.tsCapacitorregisterPluginroot-store/feature-stores.module.ts。CI/发布.github/workflows/build-ios.yml扩展 profile新 secretIOS_WIDGET_PROVISION_PROFILEexport options。十、移植守则实现时必须守住的五条不变量综合 Android 维护指南 docs/android-home-screen-widget.md 与 iOS 规划文档本次移植必须保持单一写入者快照只有 Angular 写widget_data原生只写队列渲染期叠加待定目标永不改写 blob队列化意图投递点按不直接改 App 状态进 last-wins 队列App 侧去重后应用get-and-clear 保证至少一次语义下不重放逻辑日边界原生只认validUntil这唯一判决不复刻任何日程规则dayStr 仅作展示标签同步后刷新同步窗口关闭后必须显式推送否则hydration守卫会丢掉所有窗口内发射双端 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),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价