[译] Toss a Coin to your Widget! Or don’t…

感觉是看到的比较新的分析 Android 桌面微件的文章,就顺手翻译一下了。标题实在不会翻译了……

Android 上的桌面微件已经存在了很长时间。得益于近来 iOS 引入的主屏幕小工具,桌面微件也应该会在 Android 上重新受人欢迎。

然而,由于 Android 中的桌面微件历史悠久,其 API 并不是 Android 开发中多么引人注目的部分。如果你看过桌面微件的官方教程,你马上能注意到,从小组件发布以来,文档并进行没有太多的更新。展示桌面微件的截图中还是过时的 Android 4.4 的 UI。当然我们也只能希望即将到来的 Android 12 对桌面微件 API 的更新,能够让文档得到彻底的修改。祈祷吧!

在这一系列的文章中,我们希望能深入了解 Android 桌面微件开发中的陷阱和需要注意的地方。本文并不是桌面微件的基本使用指南,毕竟官方已经有了不错的文档:https://developer.android.com/guide/topics/appwidgets

Part 1

在第一部分中,我们将关于桌面微件和它的后台任务处理。开始吧!

Android 主屏幕上一个简单的桌面微件

在微件中使用服务(也许)会带来一些麻烦

桌面微件的官方文档在介绍如何使用 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 代替了像 FirebaseJobDispatcherGcmNetworkManager 或是 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 的 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 的网格,并且,由于一般的智能手机的高度都大于宽度,因此单元格的高度和宽度是不相同的。所以桌面微件不会是正方形的。

桌面微件预览、2×2 的微件添加到手机屏幕之前和之后的样子
桌面微件预览、2×2 的微件添加到手机屏幕之前和之后的样子

如果桌面微件没有出现在预览中,检查一下桌面微件的尺寸大小和主屏幕上的可用空间。也许后者根本不够了呢。

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)也会导致错误——往下看。

Logcat 中的 AppWidgetHostView 错误和警告消息

“任性”的启动器

根据在设备上安装的启动器的不同,桌面微件也许会出现不稳定或者莫名其妙的问题。有的启动器会完全忽略 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

不过,像 initialLayoutresizeMode 等属性的更改还是会应用到已经添加的桌面微件实例中——至少在标准的 Pixel 启动器中是这样。

对于没有在已经添加的桌面微件中更新的属性,要获取桌面微件的最新状态,需要重新通过桌面微件选择器进行添加。因此,如果需要进行改动来修复错误或非预期的行为,最好是将改动的内容告知用户,并且只在重新添加桌面微件的时候才应用这些改动或是修复。

总的来说,在为 Android 开发桌面微件时会有很多需要注意的地方和陷阱。这很让人头秃,因此希望这篇文章能让开发者不再踩我们已经踩过的坑。

Android 12 对于桌面微件的改动带来了很多改进。我们将继续关注,也会乐于去深入了解新的 API!

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇