Android开发之桌面Widget开发

2,982 阅读12分钟

一、背景

Android小组件(App Widgets)是从Android 1.5(API级别3)开始引入的特性。 它们是一种用于在Android设备的桌面上显示简单信息和提供有限交互的功能组件。 小组件允许开发者将应用的部分内容或功能以简洁的形式展示在设备的桌面上,而无需用户打开整个应用。

image.png

二、实战

2.1 基本配置

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

image.png 创建之前我们需要先简单配置一些信息,比如放置的位置、尺寸调整模式、最小宽高等。接下来,我们在 /res/xml/new_app_widget_info.xml就可以看到生成的widget小组件。

<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 预览布局。

image.png

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

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

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

image.png 接下来,我们需要在AndroidManifest.xml中注册NewAppWidget,使用模版方式生成时会自动为我们注册广播接收器,并将前文中的 AppWidgetProviderInfo 元数据配置到此处。

<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>

可以看到,其实 Widget 本身也是一个 BroadcastReceiver,实现类代码如下:

class NewAppWidget : AppWidgetProvider() {
    ...
}

并且,NewAppWidget 继承自 AppWidgetProvider,而 AppWidgetProvider 的父类正是 BroadcastReceiver,AppWidgetProvider 的代码如下。

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) {}
}

具体说明如下:

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

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

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

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

其中,onUpdate() 是最重要的方法,对应的实现代码如下:

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。

2.2 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 换肤等场景。只需获取到 AppWidgetManager 和 AppWidgetIds 即可。

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 中的表现。

2.3 RemoteViews 控件更新

Widget 是通过 RemoteViews 来更新的,而 RemoteViews 并不是一个 View 或 ViewGroup,代码如下:

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

这意味着我们并不能像更新 普通的View 那样对 Widget 内的控件进行更新,只能使用 RemoteViews 提供的方法进行更新,比如上面提到的updateAppWidget()方法。

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)
}

2.4 获取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
        ...
    }
}

2.5 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)
}

可以看到,这里设置了两个 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)
}

2.6 Widget 的名称和描述

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

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

但是 Widget 名称并没有默认配置,这时系统会将应用名称作为其默认名称。当一个应用有多个 Widget 的情况下,未配置名称会让用户无法得知每一个 Widget 的作用。实际上,Widget 的名称实际上是通过配置 BroadcastReceiver 的 label 实现的。

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

对应的效果如下:

image.png

2.7 向桌面添加 Widget

在日常使用一些 App 时也许会留意到,系统允许在应用内直接向桌面添加 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() 的第三个参数传入。

参考: