直接聆听 WhatsApp 工程师分享他们如何大规模升级其 Wear OS 应用。本讲座将介绍集成 Material 3 和采用 Wear 微件背后的架构和用户体验决策。
直接了解该团队在更新应用和提供一致的用户体验时所采用的经验、模式和工具。
TransformingLazyColumn 意味着仅在一个层级应用转换,并避免使用 Compose 的 remember 缓存位置。转写文稿
大家好,
我叫 Martin。
我是 Subhag。
我们是 Meta 的工程师,来自 Wear OS 版 WhatsApp 的幕后团队。
我们最近对应用进行了现代化改造。
因此,今天我们将分享从迁移到共享代码库以及在此基础上构建新体验中获得的经验教训。
Wear OS 版 WhatsApp 的亮点在于,它无需手机即可运行。
关联后,它会作为独立的 WhatsApp 设备运行,像您的手机一样进行端到端加密,并拥有自己的数据库和服务器连接。
因此,即使手机不在身边,它也能通过 Wi-Fi 或 LTE 正常运行。
我们于 2023 年首次在 Wear OS 上推出了 WhatsApp。
它由一个用 Rust 编写的小型便携式客户端提供支持。
它帮助我们快速投入生产。
不过,这是 WhatsApp 的第二个独立实现,这意味着每个协议、加密和功能都必须构建两次。
因此,手表应用总是落后一步。
可移植性也使得处理后台变得棘手。
客户端会自行处理线程、计时器和套接字,而这些都是 Android 希望为您管理的内容。
在 Android 上,后台有规则。
在低电耗模式下,您会失去网络连接。
进程缓存后,执行时间会受到限制或无法执行。
您需要通过服务、作业或推送来解决此问题。
因此,我们的手表应用必须手动连接这些组件。
但我们的 Android 代码库已经做到了这一点。
因此,我们决定重复使用并迁移到我们的 Android 代码库,该代码库已在数十亿台设备上经过实战检验,并且已经遵循 Android 规则。
这样,新功能就不必构建两次。
目标很简单:保留业务逻辑,移除界面。
剩下的就是一个无头库,它使用 Kotlin flow 作为其 API。
不过,挑战在于,您无法一次性解耦如此庞大的应用。
有些模块将界面与基础架构混合在一起。
其他依赖项则依赖于 Wear OS 应用中不需要的功能。
因此,我们从两个方面着手:一方面继续进行模块化,另一方面剥离或覆盖我们还无法移除的内容。
我们先从模块化开始。
为了不将代码纳入 build,简单的“如果这是手机应用”语句不起作用,因为它不会移除代码。
因此,我们非常依赖 DI,尤其是可选绑定和多重绑定。
我们使用内部 DI,但它与 Dagger 提供的功能非常接近。
因此,示例使用的是 Dagger。
借助可选绑定(在 Dagger 中为 BindsOptionalOf),您可以将依赖项声明为可选,例如调用后端。
@Module
interface CallingModule {
@BindsOptionalOf
fun callingBackend(): CallingBackend
}
class Calls @Inject constructor(
val backend: Optional<CallingBackend>,
) {
fun start(): CallResult =
backend.getOrNull()?.start()
?: CallResult.NotAllowed
}然后,如果监视 build 包含提供实现的模块,则注入的后端将存在,并且调用将起作用。
如果您省略模块,则该模块将为 null,并且您的 API 可能会返回 NotAllowed。
多重绑定也适用于相同类型,但适用于集合。
@Module
interface CoreModule {
@Multibinds
fun handlers(): Set<MessageHandler>
}
@Module
interface ReactionsModule {
@Binds @IntoSet
fun reactions(impl: ReactionHandler):
MessageHandler
}
class MessageDispatcher @Inject constructor(
private val handlers: Set<MessageHandler>,
)您可以使用 multibinds 声明一个集合,例如 MessageHandler,然后每个功能模块都可以使用 into set 多重绑定添加自己的处理程序。
如果您不想支持付款,例如,您可以将整个付款模块从 build 中排除,这样注入的集合就不会包含该处理程序。
这样一来,是否支持某项功能就成了构建图的问题。
不过,这种解耦工作有利有弊。
如果使用直接引用,删除模块会导致 build 失败。
如果使用可选绑定或多重绑定,则没有任何内容引用该模块,因此 build 会通过,并且该功能会悄无声息地消失,或者默认功能会生效。
而我们恰恰遇到了这种情况。
重构将一项功能移到了其自己的模块中,而我们的 build 应用和手表应用未包含该模块。
这导致语音消息的时长显示为 0 秒。
那么,如何确保不会发生这种情况?
您需要进行测试,才能重新添加检查。
在我们的示例中,我们要做的是比较“电话”应用和手表应用之间重要接口的多重绑定集。
这样,我们就能确保始终绑定正确的实现。
现在,第二条轨道涵盖了我们还无法移除的内容。
重复使用模块可以节省工作量,但它们声明的所有内容都会合并到清单中。
手机启动器 activity 会添加一个辅助应用图标,或者不必要的 content provider 会随您的应用一起启动。
清单合并工具提供了一个实用的 tools:node="remove" 属性,可让您移除组件。
<!-- remove unused Main activity node -->
<activity android:name="com.whatsapp.Main"
tools:node="remove" />
<!-- redirect any calls to a new Activity -->
<activity-alias
android:name="com.whatsapp.Main"
android:targetActivity=".MyNewActivity" />
<provider
android:name="…MigrationContentProvider"
tools:node="remove" />一旦这些代码被移除,压缩器也可以移除这些代码。
如果仍有内容需要到达该 activity,您可以使用 activity-alias 重定向到新类。
不过,需要注意的是,这些属性仅适用于优先级较低的清单,因此合并顺序决定了是否会移除某些内容。
资源也是如此。
重复使用可让您免费获得手机字符串。
不过,有些应用承诺的功能是手表无法实现的。
我们来看一个例子,在每次对话开始时,都会显示加密通知,
在手机上,它以“了解详情”链接结尾。
不过,手表上的链接背后没有任何内容。
因此,我们会在手表上优先级较高的模块中重新声明相同的键,并将字符串替换为“请在手机上了解详情”。
<!-- phone -->
<string name="…_disclosure">… Learn more</string>
<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>然后,资源合并工具会选择我们的资源。
您还需要确保不会意外覆盖字符串。
因此,我们制定了构建规则,以确保在替换项未列入许可名单时,构建会失败。
现在,我们来谈谈通知。
默认情况下,Wear OS 会桥接或镜像手机通知。
这很好,但只有在手机连接后才能使用。
我们希望 WhatsApp 能够独立运行,因此我们的应用会发布自己的通知。
但如果桥接功能保持开启,每条消息都会振动两次。
关闭桥接功能可解决重复问题。
有一个静态清单标志,但它在用户登录之前从安装开始应用。
因此,如果用户安装了应用但未登录,就会突然收不到任何通知,这可不太好,对吧?
因此,我们使用 BridgingManager 在运行时设置它。
isRegistered.collect {
when (it) {
// watch posts its own
true -> bridging.setEnabled(false)
// phone bridges again
false -> bridging.setEnabled(true)
}
}
fun setEnabled(bridgingEnabled){
bridgingManager.setConfig(
BridgingConfig.Builder(context,
bridgingEnabled)
.build())
}您登录后,我们关闭了桥接功能。
当您退出账号时,我们会重新开启该功能。
不过,请注意,没有 API 可以读取这些值,并且我们观察到手表会丢失这些值。
因此,我们会在手表从充电器上取下或应用更新时,再次写入该数据。
我想讨论的最后一个主题是,如何退出?
当您将手表与账号解除关联时,用户希望消息也会随之移除。
因此,我们会删除所有内容,包括消息、密钥和设置。
但如何安全地做到这一点?
在后台线程仍在写入文件时删除文件,您会遇到 SQLite 异常,更糟糕的是,会写入新重新创建的数据库。
如果不停止进程,就无法真正清除其状态。
Android 提供了 clearApplicationUserData API,该 API 会清除数据并停止应用,但也会重置权限。
在某些 OEM 设备上,通知桥接与通知权限相关联,因此如果您使用此功能,手机镜像功能也会突然失效。
因此,我们自行停止了该流程。
clearActivityBackStack()
// enumerate our own, kill each
killOtherProcesses()
// no threads alive — now safe
deleteDataDirectories()
// show logged out confirmation
firePostCleanupIntent()我们在辅助进程中构建了一个微型服务,该服务没有 DI,也没有 WorkManager,因此不会启动应用的其余部分。
在您退出登录后,此服务会终止应用中的所有其他进程,然后才会在没有其他进程竞争的情况下删除所有数据。
由于我们不使用 clearApplicationUserData,因此权限会保留,我们可以开启通知桥接功能,这样手机通知就会恢复。
以上就是第一部分的内容,现在我将把时间交给 Subhag,由他来介绍我们的界面更新。
谢谢 Martin。
在共享基础架构到位后,我们的重点是让 WhatsApp 在您的手腕上感觉真正舒适。
该历程的第一部分是迁移到 Material 3,但首先,让我们问问为什么。
在圆形显示屏上,每个像素都至关重要,而 Material 3 为我们提供了合适的组件和设计基础,让我们能够打造出适合腕间佩戴的现代化 WhatsApp。
如果您注意到这些视觉效果,从表面上看,它可能只是一个界面更新。
我们当时正在从 Material 2 迁移到 Material 3,这并非一朝一夕之功。
新旧代码必须通过开关并存。
因此,虽然界面在视觉上与 Figma 匹配,但仍会出现一些异常行为。
举几个例子,表冠滚动、向右滑动返回等一些最基本的手势都无法使用。
总而言之,设计体系迁移不仅是视觉迁移,也是行为迁移。
说到行为,如果您注意到靠近边缘的行,它们会更窄,并嵌入到曲线中。
那就是 TransformingLazyColumn。
行会进行转换,以免被圆形边框截断。
动态屏幕可能会让事情变得棘手。
新消息不断涌入,行不断移动,气泡形状各异。
我们来看看如何才能做到这一点。
如果您注意到左侧,气泡收缩得太厉害了。
在内部,每条消息都是一个封装在卡片内的气泡。
我们指示父级卡片应用转换,但相同的转换也传递给了子级气泡。
因此,子气泡获得的转换量是预期转换量的两倍。
解决方法是确保我们在一个层级(即父级卡片)应用转换。
这也是一项关键原则。
我们确保每个商品只进行一次转换。
这里还有另外一个问题:气泡很小,而且在屏幕中间就褪色了,而它们本应以完整尺寸显示。
大小取决于它们在屏幕上的位置。
在初始版本中,我们使用 Compose 的 remember 函数保存了尺寸调整结果,但最终也保存了其旧位置。
现在,当新消息到达并将旧气泡向上移动时,它们的转换仍基于其旧位置。
这会导致出现该 bug。
为了解决此问题,我们停止使用 remember 调用来保存大小,并移除了该调用。
这也是一种学习。
当行移动时,其转换也必须随之移动。
在这里,您可以看到代码中的两处修复。
// FIX #1: ONE OWNER
CompositionLocalProvider(
LocalSurfaceTransformation provides null
) {
M3Card(transformation = t) {
SharedChatEntry(...)
}
}
// FIX #2: CURRENT ITEM SCOPE
val t = SurfaceTransformation(spec)// BUG #1: DOUBLE MORPH
M3Card(transformation = t) {
CompositionLocalProvider(
LocalSurfaceTransformation provides t
) { SharedChatEntry(...) }
}
// BUG #2: STALE ITEM SCOPE
val t = remember(spec) {
SurfaceTransformation(spec)
}在左上角,您可以看到转换应用于两个级别;而在右上角,转换仅应用于一个级别,即 M3 卡片。
在左下角,我们使用 remember 调用来缓存转换,但它也会缓存位置。
在右下角,我们每次都会根据气泡的最新位置计算转换。
如您所见,结果是在所有屏幕上都有一致的体验,关键内容保持在中心附近,而行则自然地移至边缘附近。
接下来,我们来谈谈通知。
现在,想象一下,您的手腕振动了一下,您查看手表,看到了一条消息。
但片刻之后,手腕又开始震动,而且震个不停。
您会收到多条通知,但每次查看时,看到的内容都一样。
这真的很烦人。
为了解决这个问题,我们问了自己两个问题。
哪些事件应触发通知来提醒用户?
哪些事件应静默更新通知?
此 bug 的原因是,在后台,个人资料照片更新、媒体下载和回执本应静默更新通知,但却向用户发出了提醒。
现在,当有新消息送达时,手腕会震动一次,而无需提醒的事件则会静默刷新通知。
这也是一个教训。
如果通知是您应用的核心用户体验历程之一,我们必须确保每条提醒都真正值得用户关注。
关于通知的主题。
之前,WhatsApp Wear OS 通知仅支持两项操作。
您可以阅读消息,也可以打开应用。
如果您想直接回复通知,之前是无法做到的。
因此,我们使用 Android 的通知操作和 RemoteInput API 弥补了这一差距。
现在,用户可以输入、说出或点按快速回复。
但请记住,点击发送并不是终点。
如果消息发送成功,我们会将聊天标记为已读,并清除通知。
如果消息发送失败,我们提供一键重试功能。
这也是开发过程中的一个关键经验,即必须将通知操作视为一个完整的端到端工作流。
最后,让我们退一步思考,归根结底,我们希望有越来越多的用户使用我们的应用。
现在,假设您作为开发者,有一天登录信息中心,发现活跃用户数量出现断崖式下跌。
首先,这会让人非常担忧。
然后,您可能会开始调试。
您可能会觉得发生了崩溃,或者发布了有问题的版本。
但对我们来说,这些都不是问题。
原来,每个 WhatsApp 应用版本都有 90 天的内置有效期。
因此,想象一下,用户被困在过时的 build 中,而较新的 build 需要时间才能到达他们的手表。
因此,用户并未离开应用,但应用仍然停止运行。
因此,我们将应用更新功能纳入了产品中。
如您在屏幕上所见,在应用到期前 15 天,屏幕上会显示此消息。
然后,在剩余 7 天、5 天、3 天、2 天和 1 天时,该通知会再次显示。
用户可以选择关闭该提示,或前往 Play 商店更新应用。
这有助于多次提醒用户其应用即将到期,并多次提醒用户更新应用。
以上就是我们总结的最后一条经验。
作为开发者,我们的工作并不会在更新发布到 100% 时结束。
只有当用户真正收到我们的更新时,更新才算完成。
以上就是全部内容。
如果您想继续交流,欢迎在咨询交流时间与我们联系。
接下来,Frank 和 Yuri 将为大家介绍 Wear 微件。
谢谢。
@Module
interface CallingModule {
@BindsOptionalOf
fun callingBackend(): CallingBackend
}
class Calls @Inject constructor(
val backend: Optional<CallingBackend>,
) {
fun start(): CallResult =
backend.getOrNull()?.start()
?: CallResult.NotAllowed
}@Module
interface CoreModule {
@Multibinds
fun handlers(): Set<MessageHandler>
}
@Module
interface ReactionsModule {
@Binds @IntoSet
fun reactions(impl: ReactionHandler):
MessageHandler
}
class MessageDispatcher @Inject constructor(
private val handlers: Set<MessageHandler>,
)<!-- remove unused Main activity node -->
<activity android:name="com.whatsapp.Main"
tools:node="remove" />
<!-- redirect any calls to a new Activity -->
<activity-alias
android:name="com.whatsapp.Main"
android:targetActivity=".MyNewActivity" />
<provider
android:name="…MigrationContentProvider"
tools:node="remove" /><!-- phone -->
<string name="…_disclosure">… Learn more</string>
<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>isRegistered.collect {
when (it) {
// watch posts its own
true -> bridging.setEnabled(false)
// phone bridges again
false -> bridging.setEnabled(true)
}
}
fun setEnabled(bridgingEnabled){
bridgingManager.setConfig(
BridgingConfig.Builder(context,
bridgingEnabled)
.build())
}clearActivityBackStack()
// enumerate our own, kill each
killOtherProcesses()
// no threads alive — now safe
deleteDataDirectories()
// show logged out confirmation
firePostCleanupIntent()// FIX #1: ONE OWNER
CompositionLocalProvider(
LocalSurfaceTransformation provides null
) {
M3Card(transformation = t) {
SharedChatEntry(...)
}
}
// FIX #2: CURRENT ITEM SCOPE
val t = SurfaceTransformation(spec)// BUG #1: DOUBLE MORPH
M3Card(transformation = t) {
CompositionLocalProvider(
LocalSurfaceTransformation provides t
) { SharedChatEntry(...) }
}
// BUG #2: STALE ITEM SCOPE
val t = remember(spec) {
SurfaceTransformation(spec)
}