从业务需求看 Widget (小组件)技术架构
一、背景
最近团队做了一个小组件需求,从开发到上线出了不少问题。
需求内容大致是可以把图片加上一些元素放到小组件里展示,比如像一个相框、一个圆形徽章(电子吧唧)。有交互,比如轮播图片、点击使徽章旋转。
在此之前,我对小组件没什么了解,我们的开发也是,当然开发同学提前做了调研。我原本以为小组件就是在桌面放个卡片,后面发现它涉及很多东西。
二、安卓小组件
最开始我以为安卓小组件就是个迷你页面,开发起来应该跟 App 内部的页面差不多。然而其实小组件与主应用保持着一定的独立性。
1.关键组件
- AppWidgetProvider:处理小部件的生命周期事件,例如创建、更新、删除等操作。
- AppWidgetProviderInfo:定义小部件的元数据信息,例如布局文件、更新频率、大小等。这个信息通常通过XML文件来定义。
- RemoteViews:定义小部件的布局和视图内容。由于小部件通常运行在宿主(主屏幕)的进程中,RemoteViews用于跨进程通信,使得应用可以更新小部件的界面。
- AppWidgetManager:管理小部件的创建、更新和删除,提供了API来和系统进行交互。
可以看到,小组件本身的 UI 渲染运行在系统 UI 进程中,而其业务逻辑默认在主进程执行,二者通过 RemoteViews 跨进程通信。
于是小组件的 UI 能力极其受限,例如那些异形相框、装饰边框,看似只是普通的图片叠加,却实在难以保证和图片尺寸贴合,在 A 机型上完美了,B 机型可能会图片大一圈突破相框边缘,或者图片过小,与相框中间产生缝隙,实测确实如此。不仅如此,安卓厂商众多,不少手机还能设置桌面的行列数,以及其他特殊的选项,实在是难以兼容。
2.旋转动画
徽章需要支持点击后旋转一圈。这在普通页面中非常简单,但在 Widget 中完全不同,RemoteViews 不支持自由动画控制,因此无法直接使用常规动画方案。
最后采用了「布局替换 + LayoutAnimation」方案。
1.触发层
用户点击组件后,发送广播,接收广播后执行动画。
而执行动画并非真的去“播放”动画,而是直接切换成另一个视图,播放完毕后再切回去。
核心代码:
val appWidgetManager = AppWidgetManager.getInstance(context)
val viewsWithRotation = getRemoteViews(context, appWidget, appWidgetId, withAnimation = true)
appWidgetManager.updateAppWidget(appWidgetId, viewsWithRotation)
delay(2000)
val views = getRemoteViews(context, appWidget, appWidgetId, withAnimation = false)
appWidgetManager.updateAppWidget(appWidgetId, views)2.布局层
一套静态无旋转的 XML,一套包含旋转一圈动画的 XML。对于动画,使用 LayoutAnimation 使得切换视图后对应控件立刻执行。
3.动画层
除了旋转1圈的2秒动画,还设计了高光的旋转、淡入等动画。中间是更换过方案的,使用本方案(切换视图)后,出现旋转前后高光会对不上,产生切换感的问题。
3.点击小组件进入 App 内设置页
这个 App 内部的页面是一个 WebView,这里把它放到独立的子进程避免影响主进程。跳转方式就是点击小组件时候打开指定路由。
val uri = "https://xxxx/widget-setting".toUri().buildUpon()
.appendQueryParameter(
"param1", xxx,
).appendQueryParameter(
"param2", xxx,
).build()这里遇到了小米某些系统 param 带不到里面去的问题,去小米应用市场把所有小米自带应用都升级的最新版后就又能带进去了。
三、iOS小组件
1.独立性
相比安卓,小组件在 iOS 上整体架构更加统一,但系统限制也更强。我自己写了个 mac 上的小组件体验了一下。
1.独立的 Target
在 Xcode 中,Widget 必须有独立的 App Extension Target,运行后,进程也是独立的。
2.通信
主 App 只能向系统提出建议刷新 Widget,系统具体真正什么时候刷新甚至会不会不给刷,都是系统来决定的。这一点上安卓应该是要宽容一些。
3.数据
只能通过 App Groups 来共享数据,内存空间完全独立。
2.旋转动画
在 iOS 小组件中,AppIntent 的交互必须绑定在具体的 UI 控件上,因此在最外层覆盖了一个透明的 Button,点击触发 AppIntent,执行 count += 1。
WidgetKit 的 widget 本身没有可变状态能用,唯一能更新内容的方式是 Provider 提供新的 Entry。所以我们让点击动作后 count+1,这样视图拿到新数据就能重新渲染。
.rotationEffect(Angle(degrees: entry.count * 360))
.animation(.easeInOut(duration: 2), value: entry.count) 然后每次 count 都加一,那么每次 degrees 就是多 360°,正好一圈。使用 easeInOut,开始时候会加速,结束时候会减速,更有物理感更自然。因为 count 一开始是0,所以初始渲染时候不会触发旋转。
3. “透明背景”
需求里要求小组件背景能设成透明,安卓有这个能力,iOS 没有,于是参考其他 App,让用户截一张空桌面的图,然后小组件获取自己当前所在位置,选择空桌面的对应区域作为背景,视觉上产生“透明背景“的效果。
在视觉上,左右切换桌面时,会有明显破绽,但暂时也没有其他好的方案。
在技术上,每个位置的起始坐标是手动标定的硬编码,维护起来非常麻烦,每一个不同尺寸的设备,包括未来要新出的设备,都要人工再精调一下,因为不同机型状态栏高度、图标网格间距等都可能有差异。不精调的话虽然也大致能用,但这本身就是一个为用户提供的展示类的服务,这种偏差是非常丑的,用户也会觉得不专业,很粗制滥造。
四、总结
本需求的难点不在“做功能”,而是:
- 跨机型稳定展示
- 在系统限制下还原设计
看似简单的功能,背后涉及复杂的逻辑。这类需求特别容易低估工作量,实际上我们也是超期了一倍不止,最后效果也不好。
以后再接这类需求,需要细致评估测试成本是否可控、验收标准是否现实。本需求很好体现了“当产品理想遇上系统现实”的矛盾。