感觉是看到的比较新的分析 Android 桌面微件的文章,就顺手翻译一下了。标题实在不会翻译了……
Android 上的桌面微件已经存在了很长时间。得益于近来 iOS 引入的主屏幕小工具,桌面微件也应该会在 Android 上重新受人欢迎。
然而,由于 Android 中的桌面微件历史悠久,其 API 并不是 Android 开发中多么引人注目的部分。如果你看过桌面微件的官方教程,你马上能注意到,从小组件发布以来,文档并进行没有太多的更新。展示桌面微件的截图中还是过时的 Android 4.4 的 UI。当然我们也只能希望即将到来的 Android 12 对桌面微件 API 的更新,能够让文档得到彻底的修改。祈祷吧!
在这一系列的文章中,我们希望能深入了解 Android 桌面微件开发中的陷阱和需要注意的地方。本文并不是桌面微件的基本使用指南,毕竟官方已经有了不错的文档:https://developer.android.com/guide/topics/appwidgets。
Part 1
在第一部分中,我们将关于桌面微件和它的后台任务处理。开始吧!
在微件中使用服务(也许)会带来一些麻烦
桌面微件的官方文档在介绍如何使用 AppWidgetProvider
类的 onUpdate()
回调时,提到了可以使用 Service (android.app.Service
):
当用户添加应用微件时也会调用此方法,所以它应执行基本设置,如定义视图的事件处理脚本以及根据需要启动临时的
Service
。
在 2017 年,从 Android 8.0 引入了后台执行限制开始,Service 的使用也受到了限制。只有在 Android 系统认为应用并没有在后台运行时,才能从 onUpdate()
中启动一个基本 Service。
如果用户刚刚切换到主屏幕并添加了新的桌面微件,这也许还能正常运行。但是,一旦应用被归为后台运行状态时,启动 Service 将会抛出 IllegalStateException
异常,从而使应用进程崩溃。因此,不再建议在不清楚这项更改的情况下使用 Service 或 IntentService 进行桌面微件的更新。
如果有通过 onUpdate()
触发的需要长时间运行的操作,可以使用诸如 JobIntentService 和 JobScheduler 的组件。遗憾的是,到目前来说,WorkManager 并不是一个好的选择。我们将在下一部分中解释这个问题。
桌面微件和 WorkManager——致命组合
在 2018 年,Google 推出了 WorkManager API,用于调度延迟执行的异步任务。即使应用退出,或设备重启,这些任务也能运行。WorkManager 代替了像 FirebaseJobDispatcher、GcmNetworkManager 或是 JobScheduler 这样的在旧版 Android 中使用的后台调度 API。FirebaseJobDispatcher 和 GcmNetworkManager 甚至在 2020 年被弃用,取而代之的是 Android WorkManager。虽然 WorkManager 是一个功能强大且易于使用的 API,但如果结合桌面微件使用,会有一个很大的问题,可能会导致对 AppWidgetProvider
类的 onUpdate()
函数进行重复而不必要的调用。这会进一步导致 UI 重复更新,从而使得桌面微件出现“闪烁”——因为桌面微件的布局在初始布局(initialLayout
)和更新后的布局中不断进行切换。
CommonsWare 有一篇很不错的文章讲述了这种副作用,解释了导致桌面微件出现这种意外行为的技术细节。尽管已经有人向 Google 提交了很多关于此副作用的 issue(例如 115575872 和 119920965),但看起来 Google 并不会很快就解决。究竟为什么 WorkManager 和桌面微件的配合会出现这样的问题呢?我们来看一下 WorkManager 的一些技术细节:
每当使用 WorkManager 调度工作时,都会注册一个 ACTION_BOOT_RECEIVER
(androidx.work.impl.background.systemalarm.RescheduleReceiver) 广播接收器,以便能在设备重启后也能重新开始计划的任务。这会导致发送 ACTION_PACKAGE_CHANGED
广播,来触发 AppWidgetProvider
类的 onUpdate()
函数。在 WorkManger 完成调度之后,ACTION_BOOT_RECEIVER
会由于不再需要而被禁用。这个操作又会发送 ACTION_PACKAGE_CHANGED
广播,也会进一步导致调用 onUpdate()
。
如果恰好在 AppWidgetProvider
类的 onUpdate()
函数中也使用得了 WorkManager,那么应用会陷入死循环。这时,如果应用中的其他内容也使用了 WorkManager,或者应用依赖的第三方框架使用了 WorkManager,只要有工作调度发生,或者 WorkManager 完成调度,那么 onUpdate()
就会被调用。
很遗憾,目前并没有针对这个问题的彻底解决办法。不过,可以避免 WorkManager 禁用 RescheduleReceiver
,这样就能通过调度另一个计划于很长时间之后(比如说 10 年,参见 115575872)的 WorkRequest
,来防止 ACTION_PACKAGE_CHANGED
广播触发。
到目前为之,我们的第一篇文章还算不错。请继续关注第二部分,我们将深入桌面微件及其 UI。干杯!
Part 2
欢迎来到关于 Android 桌面微件及其注意事项和陷阱的系列文章的第二部分。在第一部分中,我们主要关注了桌面微件的后台任务处理。而在第二部分,我们想强调一些在设计和构建桌面微件 UI 时需要考虑的内容。
文档里没有提及的“UI 缓存”——为什么桌面微件越来越大了?
如果想要更新桌面微件,我们会使用使用 RemoteViews 并指定一个布局 ID,然后调用所有想要更新最新信息的 View 的 setter。最后,把 appWidgetId
传递给 AppWidgetManager.updateAppWidget()
。
widgetIds.forEach { val views = RemoteViews( packageName, R.layout.widget_layout ).apply { // set name setTextViewText(R.id.vegetableName, vegetable.name) // set pending intent setOnClickPendingIntent(R.id.container, pendingIntent) // set image val appWidgetTarget = AppWidgetTarget(context, R.id.vegetableImage, this, it) Glide.with(context) .asBitmap() .load(vegetable.imageUrl) .into(appWidgetTarget) } AppWidgetManager.getInstance(context).updateAppWidget(it, views) }
由于 RemoteViews 对象的创建看起来和代码中对其他普通的布局的 Inflate 过程一样,因此也许你会认为这样也能 Inflate 一个新的布局。
但这里有个问题,如果在桌面微件更新过程的早期去调用 setter,那么当前数据在下一次更新时还是会保持可见状态。
因此,如果通过 addView()
向 RemoteViews
添加 View,那么首先必须在下一次更新的时候通过 removeAllView()
来删除所有旧的 View。否则在每次更新的时候,列表只会变得越来越长。
真的没有任何限制吗?
由于桌面微件的布局是基于 RemoteViews
实现的,因此无法使用自定义的 View,只能使用以下布局和组件类:
布局类
- FrameLayout
- LinearLayout
- RelativeLayout
- GridLayout
组件类
- AnalogClock
- Button
- Chronometer
- ImageButton
- ImageView
- ProgressBar
- TextView
- ViewFlipper
- ListView
- GridView
- StackView
- AdapterViewFlipper
如果应用依赖于自定义 View,或者/并且应用需要一个能够展示品牌的特定设计,这些限制就会很麻烦。记得将这些限制告诉设计师,并在开发桌面微件过程中密接进行合作,从而使得实现布局能够尽可能轻松。
在 Android 12 中,组件类新增了复合按钮,可以在桌面微件的布局中使用复选框、开关和单选框。
抱歉,方形 Y 轴——为什么 2×2 的桌面微件可能不是方形的
每个 Android 开发者都很熟悉各种各样的屏幕尺寸。遗憾的是,桌面微件也会受其影响。尽管官方文档提到了桌面微件的尺寸问题,但它其实并不是那么简单的。Android 主屏幕会被划分为包含多个单元格的网格。官方文档指出,许多手机会提供 4×4 的网格,而平板电脑可以提供更大的 8×7 网格。然而,由于文档已经过时,并且现在有了很多不同的屏幕尺寸,相比于简单的计算,通过反复尝试来确定桌面微件的尺寸会更简单。需要记住的是,像 2×2(3×3、4×4 等)的桌面微件可能不是方形的。根据文档,大多数设备都有 4×4 的网格,并且,由于一般的智能手机的高度都大于宽度,因此单元格的高度和宽度是不相同的。所以桌面微件不会是正方形的。
如果桌面微件没有出现在预览中,检查一下桌面微件的尺寸大小和主屏幕上的可用空间。也许后者根本不够了呢。
Android 12 为桌面微件的尺寸和布局提供了一系列改进后的 API,使得在不同设备和不同屏幕大小下的桌面微件尺寸更加可靠。例如,现在可以根据设备尺寸来提供响应式甚至是更精确的布局。
将圆角图片作为桌面微件的背景是很不错!但是……
设计团队决定制作一个漂亮的桌面微件,它拥有圆角,以及一张从后端获得的匹配桌面微件尺寸的背景图片。看起来要实现这些并不难。
一般情况下,可以使用 CardView。不过我们已经知道桌面微件用不了这个。
也许你想通过图片加载库来给图片进行一些调整。比如 Glide 就可以给图片加上圆角。为了能够实现目标,同时保持图片不失真,需要提供图片所需的目标尺寸。前面已经讨论过关于桌面微件大小调整的痛苦了。
对于普通的 View,可以在拥有圆角 background
(不是 src
)的 ImageView 使用 setClioToOutline(true)
方法。但是桌面微件的 View 并不支持普通 View 的所有方法,因此这也行不通。
所以最后,我们决定做一个不带圆角的桌面微件。
令人惊讶的是,在 Android 12 上,桌面微件默认拥有圆角。以后,不再需要绞尽脑汁才能将从后端获得的图片加上圆角,然后设置成桌面微件的背景了。
以上就是关于 Android 桌面微件系列文章的第二部分。在第三部分中,我们将研究如何调试桌面微件,以及桌面微件上线后需要注意的问题等。
Part 3
欢迎来到关于 Android 桌面微件的系列文章的最后一部分。在第一部分中,我们关注了在桌面微件中使用后台任务,第二部分中我们讨论了桌面微件的 UI 设计。在第三部分中,我们将深入研究如何调试桌面微件,以及桌面微件上线后需要注意的问题等。
PendingIntent
应当是唯一的
这更像是使用 PendingIntent
的常见问题,不过因为它也是桌面微件的一部分,所以需要关注一下这些东西:
PendingIntent
应当是唯一的,而不同的 extra
字段(例如用于打开产品详情页的产品 ID)并不足以使其保持唯一。可以通过设置 action
来让 PendingIntent
唯一。需要注意的是,如果桌面微件已经更新(例如,当轻触桌面微件时必须打开另一个产品的详情页),PendingIntent
也需要保持唯一。
调试问题
由于桌面微件依赖于 RemoteViews
,因此有时候调试桌面微件可能是个相当大的挑战。通常在 IDE 中的单步调试只能用于桌面微件的部分代码,并且在 RemoteViews
中发生的错误往往不是那么好分析。在实现新的桌面微件时,“加载桌面微件时出现问题”是所有开发者都会担心的一个状态。
在桌面微件只显示该文本而非实际布局时,通常表示在 Inflate 布局或更新 RemoteView
时出现了问题。一般来说,这些错误不会出现在应用的日志中,只能在 Logcat 中筛选系统日志的时候才能看到。大部分情况下错误日志会和 AppWidgetHostView
这样的 Tag 一起出现,并且提供足够的错误信息。极少数情况下,设备上安装的启动器(Launcher)也会导致错误——往下看。
“任性”的启动器
根据在设备上安装的启动器的不同,桌面微件也许会出现不稳定或者莫名其妙的问题。有的启动器会完全忽略 resizeMode
和/或尺寸限制(译者注:Nova Launcher 默认情况下允许所有桌面微件任意调整尺寸);有的启动器即使是在 WidgetProviders 不同的情况下也会缓存比其他启动器更多的桌面微件信息。这些都可能导致在开发过程中升降级应用时出现错误——哪怕是全新安装——例如桌面微件试图使用其他 AppWidgetProviders 的布局,导致在找不到 ID 时出现令人害怕的“加载微件时出现问题”。
如果桌面微件出现了任何无法用代码 bug 来解释的问题,可以通过清除启动器的缓存来尝试解决,或者换一个启动器看看桌面微件是否正常。
(译者注:在电表开发过程中,遇到过只有在某一台搭载 Android 10 的特定的 vivo Z5 上才会出现的“加载微件时出现问题”错误,而当我尝试通过数据线读取 Logcat 时,问题诡异地消失了)
名字就是一切
另一个关于桌面微件的不太会被注意到的点是,一旦桌面微件上线发布之后,就不应该再更改 WidgetProvider 类的名字或者包结构位置,否则会有破坏用户已经添加的桌面微件的风险。
启动器是通过 ComponentName
来区分桌面微件的,这也是在代码中获取 Provider 对应桌面微件的 ID 的途径。修改 AppWidgetProvider
子类的名称,或者包的结构位置,都会改变 ComponentName
。已经添加的桌面微件还保持着原来的名称,这会使得它们再也无法被正确引用,从而也就不会再进行更新。唯一的解决办法是从启动器中删除现有的桌面微件并重新添加。当然,不能要求用户这么干。
明白这一点之后,开发者应该确保 Provider 类的名称和位置固定下来,并且在以后也不再改变。否则,就需要去面对改动导致的后果。
如果使用 Proguard 或者 R8 这样的混淆工具,默认情况下就能保证 AppWidgetProvider
的子类不会被重命名。如果使用自定义配置或工具,请务必仔细检查。
上线后的更多改动?
不只更改 WidgetProvider 的标识符会影响桌面微件。如果想要修改 WidgetProvider 的 XML 文件,需要注意某些属性的更改并不会应用到已经添加的桌面微件,换句话说,这些值还会使用更改前的。下列属性会忽略改动:
minWidth
maxWidth
minHeight
maxHeight
updatePeriodMillis
不过,像 initialLayout
和 resizeMode
等属性的更改还是会应用到已经添加的桌面微件实例中——至少在标准的 Pixel 启动器中是这样。
对于没有在已经添加的桌面微件中更新的属性,要获取桌面微件的最新状态,需要重新通过桌面微件选择器进行添加。因此,如果需要进行改动来修复错误或非预期的行为,最好是将改动的内容告知用户,并且只在重新添加桌面微件的时候才应用这些改动或是修复。
总的来说,在为 Android 开发桌面微件时会有很多需要注意的地方和陷阱。这很让人头秃,因此希望这篇文章能让开发者不再踩我们已经踩过的坑。
Android 12 对于桌面微件的改动带来了很多改进。我们将继续关注,也会乐于去深入了解新的 API!