背景

应用 Widget 是可以嵌入到其他应用(如主屏幕)并接收定期更新的微型应用视图,Google 官方的翻译叫做「微件」,在国内的叫法也有许多,比如「小组件」等。

Music Widget

从 Android 1.5(Cupcake,API 3)开始,Widget 就已存在,到后来 Android 4.0(Ice Cream Sandwich,API 14)则逐渐改版调整,直到 Android 6.0(Marshmallow,API 23)交互才稳定下来。

不过由于 Android 的自由度导致不同应用的 Widget 设计风格各异,官方也没有为开发者们提供设计规范以及素材,堆积的 Widget 反而会让桌面变得凌乱,既不美观也不实用,于是 Widget 在经历了短暂的繁荣之后便日渐被人遗忘。

没想到 2020 年 iOS 14 的发布则重新将手机系统上的桌面小组件功能拉回了人们的视野中(iOS 的小组件发展史本文按下不表,有兴趣的读者可自行查阅资料),Widget 又焕发了第二春,除了官方优化外,国内系统比如 OriginOS 也交出了高分答卷。

OriginOS

说了这么多,接下来就写个 Widget 实践一下。

实践

基本配置

得益于『Android Studio』提供的各种能力,我们可以一键生成 Widget 模版。

Android Studio 创建 Widget

创建之前我们需要先简单配置一些信息,比如放置的位置、尺寸调整模式、最小宽高等。

配置 Widget 信息

虽然可以自动创建,但我们也要知道其创建了哪些东西才能够接着开发。

首先是 /res/xml/new_app_widget_info.xml,我们在上面配置的大部分信息会在这里生成:

<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/app_widget_description"
    android:initialKeyguardLayout="@layout/new_app_widget"
    android:initialLayout="@layout/new_app_widget"
    android:minWidth="40dp"
    android:minHeight="40dp"
    android:previewImage="@drawable/example_appwidget_preview"
    android:previewLayout="@layout/new_app_widget"
    android:resizeMode="horizontal|vertical"
    android:targetCellWidth="1"
    android:targetCellHeight="1"
    android:updatePeriodMillis="86400000"
    android:widgetCategory="home_screen" />

它是一个 AppWidgetProviderInfo 对象,用来描述应用 Widget 的元数据,比如预览、布局、更新频率等。

这里有几个属性需要关注,android:previewImage 是预览图片,会在添加 Widget 前展示,而在 Android 12(S,API 31)之后,它支持配置 android:previewLayout 预览布局:

android:updatePeriodMillis 用来控制定期更新的频率,但不能保证实际更新按此值正好准时发生,其不支持少于 30 分钟的值,官方建议尽可能降低更新频率,比如不超过每小时一次,以节省电池电量。

android:initialLayout 是 Widget 的布局,其基于 RemoteViews,所以它并不支持所有的 ViewViewGroup,尽管在 Android 12(S,API 31)之后扩展了支持。因此尽量使用基础的视图控件,或者在使用前查阅文档。

Widget 必须定义 android:minWidthandroid:minHeight,表示默认情况下应占用的最小空间量。当用户向其主屏幕添加微件时,Widget 占用的宽度和高度通常会超过所指定的最小值。虽然单元格的宽度和高度以及应用到 Widget 的自动外边距量可能会因设备而异,但可以使用下表根据所需占用的网格单元格数大致估算 Widget 的最小尺寸:

单元格数量(列数或行数) 可用尺寸 (dp)(minWidthminHeight
1 40dp
2 110dp
3 180dp
4 250dp
n 70 × n − 30

接下来看看 AndroidManifest.xml

<manifest ...>
    <application ...>
        ...
        <receiver
            android:name=".widget.NewAppWidget"
            android:exported="false">
            <intent-filter>
                <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
            </intent-filter>
            <meta-data
                android:name="android.appwidget.provider"
                android:resource="@xml/new_app_widget_info" />
        </receiver>
    </application>
</manifest>

生成模版自动为我们注册了广播接收器,并将前文中的 AppWidgetProviderInfo 元数据配置到此处。 根据我们已有的认知,不是只有四大组件才需要注册吗?

没错,其实 Widget 本身也是一个 BroadcastReceiver,我们来看其实现类:

class NewAppWidget : AppWidgetProvider() {
    ...
}

可以看到,其继承 AppWidgetProvider,而 AppWidgetProvider 的父类正是 BroadcastReceiver

public class AppWidgetProvider extends BroadcastReceiver {
    ...
}

AppWidgetProvider 作为一个辅助类来处理 App Widget 的广播,仅接收与 Widget 有关的广播事件,例如当更新、删除、启用和停用 Widget 时发出的广播。因此我们需要着重了解其生命周期:

public class AppWidgetProvider extends BroadcastReceiver {
    public void onReceive(Context context, Intent intent) {...}
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {}
    public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) {}
    public void onDeleted(Context context, int[] appWidgetIds) {}
    public void onEnabled(Context context) {}
    public void onDisabled(Context context) {}
    public void onRestored(Context context, int[] oldWidgetIds, int[] newWidgetIds) {}
}

onReceive() 不用过多解释,它就是实现了 BroadcastReceiver 的抽象方法,然后将各种事件分类处理重新分发到其他方法中。

onUpdate() 会在我们设定的更新频率中触发,所以更新 Widget 的相关逻辑应当在此处编写。

onAppWidgetOptionsChanged() 在首次放置 Widget 时以及每次调整 Widget 大小时都会调用,使用此回调可根据 Widget 的大小范围显示或隐藏内容。

每次从 Widget 托管应用中删除 Widget 时,onDeleted() 方法会被调用。

onEnabled() 在首次创建 Widget 实例时调用,如果用户添加多个 Widget 实例,则仅在首次添加时才会调用该方法。 同样,onDisabled() 会在宿主删除最后一个 Widget 实例时被调用。

不难看出,onUpdate() 是最重要的方法,『Android Studio』生成的模版中也仅实现了该方法:

class NewAppWidget : AppWidgetProvider() {
    override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
        for (appWidgetId in appWidgetIds) {
            updateAppWidget(context, appWidgetManager, appWidgetId)
        }
    }
}
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
    val widgetText = context.getString(R.string.appwidget_text)
    val views = RemoteViews(context.packageName, R.layout.new_app_widget)
    views.setTextViewText(R.id.appwidget_text, widgetText)
    appWidgetManager.updateAppWidget(appWidgetId, views)
}

示例代码为我们简单实现了更新逻辑,逻辑清晰,无需解释。

AppWidgetProvider 只是一个辅助类。如果希望直接接收 App Widget 广播,自行实现 BroadcastReceiver 同样可行,只需关注如下 Intent Action

  • ACTION_APPWIDGET_UPDATE
  • ACTION_APPWIDGET_DELETED
  • ACTION_APPWIDGET_ENABLED
  • ACTION_APPWIDGET_DISABLED
  • ACTION_APPWIDGET_OPTIONS_CHANGED

参照 AppWidgetProvider 的处理方式,当然还可以发送自定义的广播内容,这在处理一些过滤逻辑时很有用。

更新 Widget

值得一提的是,更新 Widget 内容可能会消耗大量的计算资源。所以官方也提供了三种更新方式:

  • 完整更新:将新的 RemoteViews 替换之前的 RemoteViews,这是计算开销最大的更新。
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
    ...
    val remoteViews = RemoteViews(context.getPackageName(), R.layout.widgetlayout).also {
        setTextViewText(R.id.textview_widget_layout1, "Updated text1")
        setTextViewText(R.id.textview_widget_layout2, "Updated text2")
    }
    appWidgetManager.updateAppWidget(appWidgetId, remoteViews)
}
  • 部分更新:将新的 RemoteViews 与之前提供的 RemoteViews 合并,以更新 Widget 的某些部分。如果 Widget 未收到至少一个完整更新,系统会忽略此方法。
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
    ...
    val remoteViews = RemoteViews(context.getPackageName(), R.layout.widgetlayout).also {
        setTextViewText(R.id.textview_widget_layout, "Updated text")
    }
    appWidgetManager.partiallyUpdateAppWidget(appWidgetId, remoteViews)
}
  • 集合数据刷新:使 Widget 中集合视图的数据失效,这会触发 RemoteViewsFactory.onDataSetChanged()
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
    ...
    appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, R.id.widget_listview)
}

除了在接收广播时更新 Widget,在应用内更新也同样可行,比如给 Widget 换肤等场景。只需获取到 AppWidgetManagerAppWidgetIds 即可:

object MyAppWidgetManager {
    fun updateAppWidget(context: Context) {
        val appWidgetManager = AppWidgetManager.getInstance(context)
        val appWidgetIds = appWidgetManager.getAppWidgetIds(ComponentName(context, NewAppWidget::class.java))
        for (appWidgetId in appWidgetIds) {
            updateAppWidget(context, appWidgetManager, appWidgetId)
        }
    }
}

如果通过发送广播的方式,也可以这样写:

object MyAppWidgetManager {
    fun updateAppWidget(context: Context) {
        val appWidgetManager = AppWidgetManager.getInstance(context)
        val appWidgetIds = appWidgetManager.getAppWidgetIds(ComponentName(context, NewAppWidget::class.java))
        val intent = Intent().apply {
            ...     // action, package, ect.
            putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
        }
        context.sendBroadcast(intent)
    }
}

还可以自定义 action 来过滤操作。

由于本质上就是 BroadcastReceiver 的特性,Widget 更新的时长和优先级尤为重要,系统通常会允许 BroadcastReceiver 最多运行 10 秒,然后会将其视为无响应并触发 ANR 错误。如果更新 Widget 需要更长的时间,使用 WorkManager 安排任务是个不错的选择。

但在 Widget 中使用 WorkManager 也会引发一些预期之外的事情,比如 WorkManager 可能会导致 Widget 频繁刷新,从而引发 Widget 闪烁,这是一个已知 Bug,Google 提供了一个另类的解决方法,是用 setInitialDelay() 方法给 WorkManager 配置一个 10 年的初始延迟。经过测试这个方法确实能够解决问题,但不优雅,Google 团队也表示未来将优化 WorkManager 在 Widget 中的表现。

RemoteViews 更新控件

上面提到,Widget 是通过 RemoteViews 来更新的,而 RemoteViews 并不是一个 ViewViewGroup

public class RemoteViews implements Parcelable, Filter {
    ...
}

这意味着我们并不能像平时更新 View 那样对 Widget 内的控件进行设置,只能使用 RemoteViews 提供的方法,比如上面提到的:

internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
    ...
    val views = RemoteViews(context.packageName, R.layout.new_app_widget)
    views.setTextViewText(R.id.appwidget_text, widgetText)
    views.setImageViewResource(R.id.appwidget_img, R.drawable.bg)
    ...
}

虽然 RemoteViews 提供了常用的方法,但这种用法仍为我们带来不少麻烦,比如我想对 Widget 里面的某个 View 动态设置背景,似乎就找不到类似 RemoteViews.setBackground() 之类的方法。不过点进 RemoteViews 的源码后发现,其实上面的这些操作都是通过反射实现的,比如:

public class RemoteViews implements Parcelable, Filter {
    ...
    public void setImageViewResource(@IdRes int viewId, @DrawableRes int srcId) {
        setInt(viewId, "setImageResource", srcId);
    }
    public void setInt(@IdRes int viewId, String methodName, int value) {
        addAction(new ReflectionAction(viewId, methodName, BaseReflectionAction.INT, value));
    }
}

所以给 View 设置背景时,我们可以为 RemoteViews 写个扩展方法:

fun RemoteViews.setBackgroundResource(@IdRes viewId: Int, @DrawableRes srcId: Int) {
    this.setInt(viewId, "setBackgroundResource", srcId)
}

这样就能实现想要的效果。

获取 Widget 尺寸

由于 RemoteViews 的限制,我们无法在 Widget 内使用自定义 View,这样类似简单常用的圆角 ImageView 都无法实现,这个时候可以考虑曲线救国,比如对图片的 Bitmap 进行裁剪处理,生成一个圆角的图片进行显示。

但是同一 Widget 在不同 ROM 下显示的尺寸都会有差异,所以使用 ImageView 作为圆角背景图展示时可能会遇到图片拉伸等问题,官方也没有提供对应的 API 供我们获取,我在 StackOverflow 上找到了一个并不完美的解决方案

/**
 *  获取 Widget 尺寸
 *
 *  @param context Do not pass Application context
 */
class WidgetSizeProvider(private val context: Context) {

    private val appWidgetManager = AppWidgetManager.getInstance(context)

    fun getWidgetsSize(widgetId: Int): Pair<Int, Int> {
        val isPortrait = context.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT
        val width = getWidgetWidth(isPortrait, widgetId)
        val height = getWidgetHeight(isPortrait, widgetId)
        val widthInPx = context.dip(width)
        val heightInPx = context.dip(height)
        return widthInPx to heightInPx
    }

    private fun getWidgetWidth(isPortrait: Boolean, widgetId: Int) = if (isPortrait) {
        getWidgetSizeInDp(widgetId, AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
    } else {
        getWidgetSizeInDp(widgetId, AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH)
    }

    private fun getWidgetHeight(isPortrait: Boolean, widgetId: Int) = if (isPortrait) {
        getWidgetSizeInDp(widgetId, AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT)
    } else {
        getWidgetSizeInDp(widgetId, AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT)
    }

    private fun getWidgetSizeInDp(widgetId: Int, key: String) = appWidgetManager.getAppWidgetOptions(widgetId).getInt(key, 0)

    private fun Context.dip(value: Int): Int = (value * resources.displayMetrics.density).toInt()
}

测试过程中发现,在平板上这种方法判断横竖屏不准确,Widget 中也无法监听屏幕旋转,于是加上宽高的判断:

/**
 *  获取 Widget 尺寸
 *
 *  @param context Do not pass Application context
 */
class WidgetSizeProvider(private val context: Context) {
    ...
    fun getWidgetsSize(widgetId: Int): Pair<Int, Int> {
        val isPortrait = ScreenUtils.getScreenHeight(context) > ScreenUtils.getScreenWidth(context)
                && context.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT
        ...
    }
}

但是目前仍有一个问题无法解决,系统允许设置桌面图标网格列数,默认情况下,大多数手机桌面图标网格为 4 列。

桌面网格设置

绝大多数手机在 4 列的情况下获取到的宽高都没问题,或者说即使有误差但视觉上不易察觉出来,当切换到 5 列时,通过以上方法在很多厂商定制的系统(HarmonyOS、MagicOS、REDMAGIC OS 等)中出现了严重的误差,通过计算得知这些系统在返回宽度时是按照每列固定的宽度返回,而不是实际占据的宽度。

举个例子,比如在 4 列的情况下,图标所占用的宽度为 64dp,两个图标间隔为 16dp,这样一个横向铺满的 Widget 宽度为 304dp;当切到 5 列时,这些定制的系统返回的结果是 384dp

获取 Widget 宽度异常

问题就出在这里,一个横向铺满的 Widget,无论列数如何变更,它们的宽度差值应该不会超过一个间隔的宽度,这些厂商粗暴的计算导致返回的结果超过了小组件实际的宽度,甚至超过了屏幕的宽度。

而测试发现,在搭载原生 Android 系统的 Google Pixel 上却没有此问题! 虽然这种获取尺寸的方法并非官方认证,但不同系统的割裂情况依然让我头疼。

我猜测,产生这个问题的原因可能是因为从 Android 12 开始官方才支持调整桌面图标网格,而大多数 ROM 在很早之前就使用自己的方式实现了,但并没有考虑到 Widget 尺寸的问题,同时即使 Android 12 官方已经实现该功能,这些厂商因为种种原因没有迁移反而将屎山代码流传下来所导致。

从 Widget 中启动应用

一般情况下点击 Widget 会启动应用,行为与在桌面点击应用图标一致,冷启动时进入启动页,热启动时进入到退到后台时所在的页面。但在 Android 12 上,该默认行为被取消了,也就是说不设置点击事件的情况下,点击 Widget 将不会自动启动应用,我们可以尝试给它构建一个无路径的 Intent 来解决该问题:

internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
    val views = RemoteViews(context.packageName, R.layout.new_app_widget)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        val stackBuilder = TaskStackBuilder.create(context).addNextIntent(Intent())
        val pendingIntent = stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
        views.setOnClickPendingIntent(R.id.appwidget_root, pendingIntent)
    }
    appWidgetManager.updateAppWidget(appWidgetId, views)
}

要是想修改该行为也是可以的:

internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
    val views = RemoteViews(context.packageName, R.layout.new_app_widget)
    val stackBuilder = TaskStackBuilder.create(context).apply {
        addNextIntentWithParentStack(Intent(context, MainActivity::class.java))
        addNextIntent(Intent(context, WidgetActivity::class.java))
    }
    val pendingIntent = stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
    views.setOnClickPendingIntent(R.id.appwidget_text, pendingIntent)
    appWidgetManager.updateAppWidget(appWidgetId, views)
}

这里看到设置了两个 Activity,其中 WidgetActivity 是要跳转的页面,MainActivity 是首页,这是一个很常见的交互,即小组件跳转到具体页面后返回直接回到应用主页,而不是退出应用。通过 addNextIntentWithParentStack() 可以构建包含返回栈的 PendingIntent

仍需要注意的是,一般应用会在闪屏页执行一些初始化操作,但如果像上面修改了 Widget 的启动页面后,应用不经闪屏页即进入 WidgetActivity,会导致某些功能出现异常,所以可以考虑做一个中间页跳转,在中间页做初始化操作,或者直接复用闪屏页功能,根据不同情况跳转。

当 Widget 内不同的 View 需要响应不同的 PendingIntent 时,我们习惯性会通过 putExtra() 方法给同一个键传递不同的值,但在这里你可能会发现,后一个设置的值总会覆盖前一个,比如:

internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
    val views = RemoteViews(context.packageName, R.layout.new_app_widget)
    val stackBuilder1 = TaskStackBuilder.create(context).apply { 
        ...
        addNextIntent(Intent(context, WidgetActivity::class.java).putExtra("arg", 1))
    }
    val pendingIntent1 = stackBuilder1.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
    views.setOnClickPendingIntent(R.id.appwidget_text, pendingIntent2)
    val stackBuilder2 = TaskStackBuilder.create(context).apply {
        ...
        addNextIntent(Intent(context, WidgetActivity::class.java).putExtra("arg", 2))
    }
    val pendingIntent2 = stackBuilder2.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
    views.setOnClickPendingIntent(R.id.appwidget_img, pendingIntent2)
    appWidgetManager.updateAppWidget(appWidgetId, views)
}

无论点击哪一个 View,我们在 WidgetActivity 接收数据会发现都是 2,这显然是不合理的。究其原因,是系统把这两个 PendingIntent 都当成同一个去处理了,我们有两种方法可以避免这个问题。

一是为 Intent 添加不同的 action

internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
    val stackBuilder1 = TaskStackBuilder.create(context).apply { 
        ...
        addNextIntent(Intent(context, WidgetActivity::class.java).putExtra("arg", 1).setAction("action_1"))
    }
    val stackBuilder2 = TaskStackBuilder.create(context).apply {
        ...
        addNextIntent(Intent(context, WidgetActivity::class.java).putExtra("arg", 2).setAction("action_2"))
    }
    ...
    appWidgetManager.updateAppWidget(appWidgetId, views)
}

二是指定唯一的 requestCode

internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
    val pendingIntent1 = stackBuilder1.getPendingIntent(1, PendingIntent.FLAG_UPDATE_CURRENT)
    val pendingIntent2 = stackBuilder2.getPendingIntent(2, PendingIntent.FLAG_UPDATE_CURRENT)
    ...
    appWidgetManager.updateAppWidget(appWidgetId, views)
}

这两种方案都可以为每个 View 区分 PendingIntent 并传递不同的参数,确保它们能够按照我们期望的方式执行。

Widget 的名称和描述

Widget 的描述是从 Android 12(S,API 31)开始支持的,也就是上文模版中配置的:

<appwidget-provider ...
    android:description="@string/app_widget_description" />

但是 Widget 名称并没有默认配置,这时系统会将应用名称作为其默认名称。当一个应用有多个 Widget 的情况下,未配置名称会让用户无法得知每一个 Widget 的作用,比如看『豆瓣』的几个 Widget 你能分辨出它们分别是什么吗:

豆瓣的 Widget 没有配置名称

Widget 的名称实际上是通过配置 BroadcastReceiverlabel 实现的:

<manifest ...>
    <application ...>
        ...
        <receiver
            android:name=".widget.NewAppWidget"
            android:exported="false"
            android:label="@string/widget_name">
            ...
        </receiver>
    </application>
</manifest>

效果如下:

应用内向桌面添加 Widget

你在日常使用一些 App 时也许有留意到,系统允许在应用内直接向桌面添加 Widget:

应用内向桌面添加 Widget

这个功能实际上是从 Android 8(Oreo,API 26)开始提供的:

fun addWidgetToHomeScreen(context: Context) {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
        return
    }
    val appWidgetManager = AppWidgetManager.getInstance(context)
    val provider = ComponentName(context, NewAppWidget::class.java)
    if (appWidgetManager.isRequestPinAppWidgetSupported) {
        val intent = Intent(context, NewAppWidget::class.java)
        val successCallback = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
        appWidgetManager.requestPinAppWidget(provider, null, successCallback)
    }
}

如果应用不需要收到系统是否成功将 Widget 固定到受支持的启动器上的通知,可以将 null 作为 requestPinAppWidget() 的第三个参数传入。

总结

以上就是构建 App Widget 的简单介绍,同时也涉及到 RemoteViewsPendingIntentWorkManager 的一些坑,事实上还有许多配置未提及,另一方面 Android 12(S,API 31)这个版本也为 Widget 扩展了许多能力,本文未能一一介绍,如需要更高级的定制功能,可自行查阅文档。

参考内容