作者实现跨平台桌面小组件时遇到的隐私问题与解决方案,包括Android Glance和iOS WidgetKit的具体实现细节。
我在开发 Affirmi,一款基于情绪的肯定语应用。
Affirmi 的理念非常简单:
你的肯定语应该与你当下的真实感受相关。
很多肯定语应用都采用相同的方式:你打开应用,按下一个按钮,得到一句随机的正能量句子。
这不是我真正想要做的。
如果我某天过得很糟糕,我不一定会想被随机地灌输"你无所不能!"这样的话。我想要的是真正承认我当下状态的东西。
所以 Affirmi 会根据你的情绪和其他上下文来生成更贴合你当前状态的肯定语。
对于应用本身,我选择了 Kotlin Multiplatform。它让我能够在 Android 和 iOS 之间共享大量代码,同时仍然允许每个平台在有意义的地方使用原生 API。
而最后这一点,在本周变得尤为重要。
我开始开发一个听起来很简单、直到真正实现才发现并不简单的功能:
把 Affirmi 的肯定语放到主屏幕上。
像 Affirmi 这样的应用有一个有趣的问题。
我并不希望用户每次需要肯定语时都打开应用。
如果目标是在某人一天中提供一个小小的鼓励时刻,让他们经历:
解锁手机 → 找到应用 → 打开应用 → 等待 → 阅读肯定语
这一步骤已经太过繁琐了。
小组件改变了这一点。
这样的话,肯定语可以在你拿起手机时就静静地出现在那里。
这使得小组件成为那种看起来只是一个小小的 UI 改动、但实际上能改变产品使用方式的功能。
然后我开始实现它。
这时我发现了一件也许我应该预料到的事:
Android 和 iOS 对小组件的实际定义有非常大的差异。
第一课:小组件不仅仅是另一个页面
从一个普通的应用架构出发,很容易这样理解小组件:
App
└── Widget Screen
└── ViewModel
└── Repository
└── API
不幸的是,这并不是小组件真正的工作方式。
小组件存在于系统管理的环境中。当小组件需要展示内容时,你的应用并不保证处于运行状态。
这改变了架构设计。
重要的问题不是:
"如何复用我现有的页面?"
而是:
"小组件需要的最小数据是什么?如何确保在我应用不运行时这些数据依然可用?"
这个问题最终导致了两种不同的实现方案。
Android:Glance + WorkManager
在 Android 上,我使用了 Jetpack Glance。
Glance 是 Google 用来构建应用小组件的框架,使用了熟悉 Jetpack Compose 的概念。
这对 Affirmi 特别有吸引力,因为我其他的 Android UI 已经使用了 Compose。
但有一个重要的区别:
Glance 并不是简单地在小组件里运行 Compose。
Glance 最终生成的是 Android 需要用于 App Widget 的表示形式,而不是给你一个像普通应用页面那样运行的 Compose UI 层级。
这意味着你必须用不同的方式思考状态和生命周期。
小组件可以独立于主应用中发生的任何事情,被移除、重新创建、恢复或更新。
所以我不希望小组件变成另一个需要重建 Affirmi 整套应用架构的地方。
相反,我给了它一个非常小的数据契约:
概念上类似于:
WidgetAffirmationPayload
- affirmation
- mood
- timestamp
- isPrivate
小组件不需要知道肯定语是如何生成的。
它不需要知道推荐算法。
它不需要知道用户的情绪历史。
它只需要知道:
"我现在应该展示什么?"
这种分离被证明是非常有用的。
那么小组件什么时候更新呢?
这是下一个问题。
我想要两种更新。
如果我打开 Affirmi 并记录了新的情绪,小组件理想情况下应该立即反映这个变化。
所以在生成新内容后,我会触发一次小组件更新。
User logs mood
↓
Affirmi generates affirmation
↓
Save latest affirmation
↓
Update widget
但如果用户没有打开 Affirmi 会怎样呢?
我仍然希望小组件有机会刷新。
这就是 WorkManager 的用武之地。
我使用周期性后台任务作为基线刷新机制,并设置了网络约束,这样工作线程在连不上网时不会不必要的尝试网络操作。
还有一个重要的兜底机制:
如果网络不可用,小组件会使用上一次缓存的肯定语。
这是一个小细节,但我认为是一个重要的细节。
对于这类产品,过时的内容通常比没有内容要好。
我宁愿有:
"你不必在今天就把所有事情都想清楚。"
这是当天早一些时候的内容,而不是一个突然显示:
"无法加载肯定语。"
的小组件。
iOS:这里开始变得有趣了
iOS 的实现迫使我重新思考架构。
Apple 的 WidgetKit 小组件作为扩展运行,而不是简单地作为应用内的另一个页面。
这对 Kotlin Multiplatform 非常重要。
我的 iOS 主应用可以访问共享的 KMP 代码。
WidgetKit 扩展并不能简单地实例化我正常的 KMP 应用栈并开始调用仓库。
所以我没有试图把小组件塞进现有架构,而是决定把它当作它真正的样子来对待:
一个非常小的共享数据消费者。
这让我想到了 App Groups。
App Groups 成为了桥梁
基本架构是这样的:
Affirmi iOS App
│
│ generates
│ affirmation
↓
Shared App Group container
│
│ reads
↓
Widget Extension
│
↓
WidgetKit
主应用将序列化的 WidgetAffirmationPayload 写入共享的 App Group 容器。
小组件扩展然后读取该 payload。
小组件不需要运行 Affirmi 的整个领域层。
它不需要自己发起网络请求。
它不需要知道推荐引擎是如何工作的。
它只需要读取最新数据并渲染。
当应用更新共享数据时,它也可以请求 WidgetKit 重新加载相关的时间线。
这种分离使得 iOS 架构更加清晰。
基于时间线的思考方式截然不同
WidgetKit 还引入了另一个需要一些时间来适应的概念:
"每隔 X 分钟刷新此小组件。"
你给 WidgetKit 提供代表小组件应该显示什么内容的条目,以及一个关于何时请求更多数据的策略。
这是一个与普通应用页面完全不同的心智模型。
而且重要的是,iOS 控制实际的执行。
你可以请求重新加载,但你不应该把你的架构建立在操作系统会 exactly 在你请求的时间执行你的请求的假设上。
这是移动开发教给你的一个有用教训的地方之一:
操作系统是你运行时的一部分。
你不能完全控制后台工作何时发生。
你要围绕平台实际给你的保证来设计。
然后我遇到了一个我没有考虑到的问题
肯定语本身。
内容的隐私影响。
Affirmi 不是在展示天气。
它不是在显示股价。
它不是在显示日历约会。
它展示的是可能与一个人感受直接相关的东西。
想象一下,有人记录了他们感到焦虑、压力山大、孤独或度过了艰难的一天。
现在想象那条肯定语出现在锁屏上。
站在他们旁边的任何人都可能看到它。
这与大多数小组件内容是完全不同的隐私问题。
锁屏不是主屏幕
这让我做出一个深思熟虑的产品决策。
对于主屏幕小组件,Affirmi 默认可以显示实际的肯定语。
手机已经解锁了,而且用户是主动把小组件放在主屏幕上的。
但对于锁屏小组件,我希望有一个更安全的默认值。
所以锁屏版本可以以遮罩状态开始。
大致如下:
Affirmi 点击查看今日肯定语。
而不是立即显示潜在的私人文字。
如果用户愿意,他们可以明确选择显示真实的肯定语。
这看起来可能是一个微小的配置细节。
但对于健康、日志记录、心理健康或情绪类产品,隐私不应该是单一的全局设置。
对于解锁的主屏幕有意义的设置,可能对锁屏没有意义。
这是我做这个功能学到的最喜欢的教训之一。
如果我重新开始,会做哪些不同的事
对于构建类似功能的任何人,我有几点建议。
在接触 UI 之前,先定义小组件需要的最小 payload。
WidgetAffirmationPayload
├── affirmation
├── mood
├── timestamp
└── privacy state
这给了两个平台一个共同的概念模型,尽管它们的实现完全不同。
这也防止了小组件与应用架构产生耦合。
不要假设网络总是可用的。
小组件应该有一个有用的缓存状态。
对于 Affirmi,即使手机离线,上一次成功生成的肯定语也是有价值的。
对于一个应该在一整天中的零碎时刻都可用的产品来说,这一点尤其重要。
Android 和 iOS 给你提供了不同的后台执行模型。
试图让它们表现完全相同通常会制造不必要的复杂性。
使用 KMP,我在有意义的地方共享业务逻辑和数据模型。
但当我进入深度平台特定的领域时,我会让 Android 做 Android 的事,让 iOS 做 iOS 的事。
这是我喜欢 Kotlin Multiplatform 的原因之一。
共享代码不等于相同代码。
共享代码意味着在共享提供价值的地方共享代码。
不要在 UI 完成后才把隐私功能附加到小组件上。
对于像 Affirmi 这样的产品,数据本身可能是敏感的。
所以隐私需要从一开始就是小组件 payload、存储策略和展示逻辑的一部分。
我最终采用的架构
从高层次来看,结果是这样的:
Affirmi
│
Shared KMP Layer
│
┌────────────┴────────────┐
│ │
Android iOS
│ │
┌──────┴──────┐ ┌──────┴──────┐
│ │ │ │
Glance WorkManager App Group WidgetKit
│ │ │ │
└──────┬──────┘ └──────┬──────┘
│ │
└──────── Widget Data ────┘
│
Latest affirmation
有趣的是,用户在两个平台上看到的基本上是相同的功能。
然而在底层,实现是非常不同的。
我喜欢这个功能的地方
我在构建 Affirmi 时还处于早期阶段,但这就是那种让我觉得产品更有用的功能。
目标不是做另一个人们因为通知告诉他们而每天打开一次的应用。
我宁愿让 Affirmi 静静地成为某人一天的一部分。
你解锁手机。
你看到一条肯定语,它真的与你当下的感受相关。
也许你花五秒钟读它。
也许这就是你所需的全部。
这就是我试图构建的产品。
而小组件是让这一切发生的一小步。
对我来说最大的收获实际上与 Jetpack Glance 或 WidgetKit 无关。
当你构建跨平台软件时,用户作为一个功能体验到的东西,不一定需要作为一个架构来实现。
Kotlin Multiplatform 给了我一个很棒的地方来共享真正受益于共享的部分——领域逻辑、模型、网络、仓库和其他应用逻辑。
但当我进入操作系统特定领域的那一刻,我需要尊重平台。
Android 有它自己的小组件生命周期和后台执行模型。
iOS 有 WidgetKit、扩展、时间线、App Groups 以及它自己的执行约束。
试图把所有这些都隐藏在一个虚假的"通用小组件架构"后面,可能会让代码变得更糟,而不是更好。
更好的方法是共享意图、数据契约和业务规则——然后让每个平台处理最后一公里。
这就是我对 Affirmi 所做的。
现在,你不用打开应用来获取肯定语,而是可以把它放在你每天已经看几十次的地方:
你的手机主屏幕。