1、概述
Widget(微件)是嵌入在手机桌面的轻量级应用视图,用户从手机桌面进行访问,通常用于展示关键信息和高频操作,旨在缩短操作路径,增强用户体验。常见的Widget比如:天气、搜索、新闻资讯等。

2、基础功能
2.1、案例介绍
本文以下图样式为例,核心是搜索加菜单的功能,其中涉及到菜单动态更新、深色模式适配、登录态检测等功能。

2.2、基础实现
在Android Studio中,我们可以通过右键>New>Widget>App Widget
来创建一个Widget模板。

模板会帮我们生成这些文件:AppWidgetProvider类、app_widget_info.xml文件、以及一些res资源文件等。
2.2.1、属性配置
app_widget_info.xml文件位于res/xml/
目录下,用来配置widget的属性,比如大小、更新间隔时长等。
plain
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_widget_description"
android:initialKeyguardLayout="@layout/xy_app_widget"
android:initialLayout="@layout/xy_app_widget"
android:minWidth="280dp"
android:minHeight="110dp"
android:previewImage="@drawable/widget_preview"
android:resizeMode="none"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />
常用属性:
- minWidth、minHeight:设置最小宽高,android 12以后使用targetCellWidth、targetCellHeight;
- initialLayout:widget对应的视图xml文件;
- previewImage:添加widget时展示在widget列表中的预览图;
- description:widget描述,在widget列表中展示;
- updatePeriodMillis:widget更新间隔时长,最短30分钟,更短则需要用到AlarmManager;
- resizeMode:调整widget大小的规则,属性的值包括 horizontal、vertical 和 none;
- configure:用于配置widget属性的activity,可选;
配置好之后,在清单文件中声明:
plain
<receiver
android:name=".widget.XYAppWidget"
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/xy_app_widget_info" />
</receiver>
通过meta-data让广播类关联上配置文件。
2.2.2、事件回调
plain
class NewAppWidget : AppWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
// There may be multiple widgets active, so update all of them
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId)
}
}
override fun onEnabled(context: Context) {
// Enter relevant functionality for when the first widget is created
}
override fun onDisabled(context: Context) {
// Enter relevant functionality for when the last widget is disabled
}
}
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
val widgetText = context.getString(R.string.appwidget_text)
// Construct the RemoteViews object
val views = RemoteViews(context.packageName, R.layout.new_app_widget)
views.setTextViewText(R.id.appwidget_text, widgetText)
// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, views)
}
自定义类继承自AppWidgetProvider,AppWidgetProvider是四大广播BroadcastReceiver的子类,用来处理widget的广播,比如更新、删除、停用、启用时发出的广播,可以理解它是widget中的事件处理器。
- onUpdate:根据updatePeriodMillis更新间隔时间调用此方法;
- onEnabled:首次添加widget时调用此方法;
- onDisabled:删除最后一个widget时调用此方法;
- onDeleted:每次删除widget都会调用此方法;
- onAppWidgetOptionsChanged:首次添加widget时以及每当调整widget大小时,都会调用此方法;
- onReceive:每个广播都会调用此方法,并且是在上述各个回调方法之前调用,在父类的onReceive方法中,根据action匹配去调用的上述事件;
源码:

Widget和系统的交互流程:

2.2.3、处理onUpdate事件
最重要的回调就是onUpdate回调,Widget的视图创建、点击事件处理、更新事件处理等都在这个回调方法里面。
plain
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
// There may be multiple widgets active, so update all of them
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId)
}
}
主要看updateAppWidget方法:
plain
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)
}
- RemoteViews:Widget的布局视图
R.layout.new_app_widget
; - appWidgetManager.updateAppWidget:更新Widget视图;
至此,一个最基本的Widget就实现了,可以添加到手机桌面看效果了。
3、进阶使用
上面是基本实现,下面看看要实现案例的效果还需要做哪些。
3.1、跨进程通信
在Widget开发中,处理点击事件,我们需要用到PendingIntent来构建事件,支持Activity、Broadcast、Service,然后通过RemoteViews把事件绑定到View组件上。
以下是按钮点击跳转到Activity的例子:
plain
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
appWidgetIds.forEach { appWidgetId ->
val pendingIntent: PendingIntent = PendingIntent.getActivity(
/* context = */ context,
/* requestCode = */ 0,
/* intent = */ Intent(context, ExampleActivity::class.java),
/* flags = */ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val views: RemoteViews = RemoteViews(
context.packageName,
R.layout.appwidget_provider_layout
).apply {
setOnClickPendingIntent(R.id.button, pendingIntent)
}
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}
- 先构建PendingIntent事件,用于启动Activity。
- 通过setOnClickPendingIntent把事件绑定到View组件上。
Widget运行在Launcher进程,与APP宿主进程相互隔离。这种进程隔离机制导致:涉及应用状态(如登录态)的通信必须通过跨进程方案实现。
前文讲到AppWidgetProvider类继承自BroadcastReceiver,实现onReceive方法可以用来接收事件。
PendingIntent构建事件也支持Broadcast,可以用来发送事件。
3.1.1、发送事件
先创建广播Intent,然后绑定广播Intent到pendingIntent上,最后绑定点击事件。
plain
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
appWidgetIds.forEach { appWidgetId ->
// 广播Intent
val clickIntent = Intent(context, WidgetClickReceiver::class.java).apply {
action = "ACTION_REFRESH"
putExtra("widget_id", widgetId)
}
// 构建PendingIntent
val pendingIntent: PendingIntent = PendingIntent.getBroadcast(
context,
0,
clickIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val views: RemoteViews = RemoteViews(
context.packageName,
R.layout.appwidget_provider_layout
).apply {
setOnClickPendingIntent(R.id.button, pendingIntent)
}
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}
3.1.2、接收事件
实现onReceive方法,通过action匹配事件,比如接收到登入登出的事件之后去更新Widget的视图。
plain
class XYAppWidget : AppWidgetProvider() {
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == XY_LOGIN_ACTION || intent?.action == XY_LOGOUT_ACTION) {
val appWidgetManager = AppWidgetManager.getInstance(context)
val appWidgetIds = appWidgetManager.getAppWidgetIds(ComponentName(context, XYAppWidget::class.java))
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId)
}
}
super.onReceive(context, intent)
}
}
3.2、登录状态检测
在业务场景中,Widget中的部分功能需要登录才可以使用,未登录的情况下需要展示「去登录」的视图。

登录状态可以在上文的跨进程接收事件中知道,剩下的就是更新视图了。
我们需要一个「未登录」的视图Layout,然后在更新Widget时根据登录态来加载不同的视图布局。
plain
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, allUpdate: Boolean = true) {
val views = if (XYWidgetManager.isLogin()) {
/**
* 登录状态
*/
RemoteViews(context.packageName, R.layout.xy_app_widget).apply {
// ...
}
} else {
/**
* 未登录状态
*/
RemoteViews(context.packageName, R.layout.xy_app_widget_login).apply {
// ....
}
}
appWidgetManager.updateAppWidget(appWidgetId, views)
}
3.3、Widget集合列表

案例中Widget最下面一排是可配置的菜单列表,因为是动态的,并不能在布局中写死,所以我们需要用到列表组件,Widget布局基于RemoteViews,但是RemoteViews并不是每种视图组件都支持,仅支持以下这些布局组件:

所以,通常列表使用RecyclerView来渲染的方式就行不通了,会提示:
@layout/xy_app_widget includes views not allowed in a RemoteView: androidx.recyclerview.widget.RecyclerView
只能考虑支持的GridView了。
3.3.1、RemoteViewsService
我们需要自定义Service继承RemoteViewsService,并实现onGetViewFactory接口。RemoteViewsService主要是用来给集合视图提供数据源,onGetViewFactory接口返回的XYWidgetFactory类就是列表组件的数据适配器。
plain
class XYWidgetService : RemoteViewsService() {
override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
return XYWidgetFactory(this.applicationContext)
}
}
别忘了在manifest文件中声明:
plain
<service
android:name=".widget.XYWidgetService"
android:exported="true"
android:permission="android.permission.BIND_REMOTEVIEWS" />
3.3.2、适配器
自定义类继承RemoteViewsService.RemoteViewsFactory,并实现onCreate、getViewAt等方法。
plain
class XYWidgetFactory(private val context: Context) : RemoteViewsService.RemoteViewsFactory {
private var items = mutableListOf<IconEntry>()
override fun onCreate() {
items = XYWidgetManager.getFavoriteBench()
}
override fun onDataSetChanged() {
items = XYWidgetManager.getFavoriteBench()
}
override fun onDestroy() {}
override fun getCount(): Int {
return items.size
}
override fun getViewAt(position: Int): RemoteViews {
val views = RemoteViews(context.packageName, R.layout.item_widget_menu).apply {
if (items.isNotEmpty()) {
setTextViewText(R.id.tv_widget_menu_name, items[position].name)
this.setOnClickFillInIntent(R.id.ll_widget_menu, Intent().apply {
putExtra("widget_item_url", items[position].entryUrl)
})
}
}
return views
}
override fun getLoadingView(): RemoteViews {
return RemoteViews(context.packageName, R.layout.xy_app_widget_loading_view)
}
override fun getViewTypeCount(): Int {
return 1
}
override fun getItemId(position: Int): Long {
return position.toLong()
}
override fun hasStableIds(): Boolean {
return true
}
}
3.3.3、处理列表事件
在onUpdate方法中,我们通常用setOnClickPendingIntent来给View绑定点击事件,但不能在列表中使用,在列表中添加事件则是用setOnClickFillInIntent。
plain
this.setOnClickFillInIntent(R.id.ll_widget_menu, Intent().apply {
putExtra("widget_item_url", items[position].entryUrl)
})
这里在事件中通过Intent传递要跳转的路由地址,我们还需要与PendingIntent结合把Intent的数据传出去。
在RemoteViews中对数据和事件进行绑定:
plain
RemoteViews(context.packageName, R.layout.xy_app_widget).apply {
// ...
setRemoteAdapter(R.id.gv_widget_list, Intent(context, XYWidgetService::class.java))
val itemClickPendingIntent: PendingIntent = Intent(
context,
XYAppWidget::class.java
).run {
action = ITEM_CLICK_ACTION
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
PendingIntent.getBroadcast(context, 0, this, PendingIntent.FLAG_UPDATE_CURRENT)
}
this.setPendingIntentTemplate(R.id.gv_widget_list, itemClickPendingIntent)
}
这里是通过广播PendingIntent.getBroadcast发送了点击事件,并传递数据。
在onReceive中匹配action,然后获取路由地址进行跳转。
plain
override fun onReceive(context: Context, intent: Intent?) {
// ...
if (intent?.action == ITEM_CLICK_ACTION) {
val url = intent.getStringExtra("widget_item_url")
url?.let {
// 从BroadcastReceiver中启动一个activity需要后台弹窗权限,Manifest.permission.SYSTEM_ALERT_WINDOW
if (Settings.canDrawOverlays(context)) {
Toast.makeText(context, "加载中", Toast.LENGTH_SHORT).show()
// 权限被用户授权
val itemScheme = URLEncoder.encode(url, "UTF-8")
val itemUri = Uri.parse("xy_widget://?widget_url=$itemScheme")
val itemIntent = Intent(Intent.ACTION_VIEW, itemUri).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(itemIntent)
} else {
// 用户拒绝授权,或者设置中的权限管理导致权限没被授予
// android 11开始 后台toast不会显示
Toast.makeText(context, "请在APP设置中打开「悬浮窗」和「后台弹窗」权限", Toast.LENGTH_LONG).show()
createNotification(context, "请在APP设置中打开「悬浮窗」和「后台弹窗」权限,点击去设置")
// 这里也没法跳转设置页,因为没有权限。。
}
}
}
super.onReceive(context, intent)
}
这里的点击跳转我并没有直接去打开落地页,而是新增了一个透明Activity作为唤端容器,在唤端容器里面去分发。这样做的好处是方便校验登录态、埋点等自定义操作。
plain
// 唤端协议
val itemUri = Uri.parse("xy_widget://?widget_url=$itemScheme")
// 唤端协议配置
<activity
android:name=".widget.WidgetRouterActivity"
android:noHistory="true"
android:screenOrientation="portrait"
android:theme="@style/widgetTranslucentNoTitle">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="xy_widget" />
</intent-filter>
</activity>
注意:
从BroadcastReceiver中启动一个activity需要后台弹窗权限Manifest.permission.SYSTEM_ALERT_WINDOW。
而且需要「悬浮窗」和「后台弹窗」权限都打开。
3.3.4、集合交互流程图

3.4、加载网络图片
因为我们菜单是可配置、动态的,就需要我们在列表中去加载网络图片。
但由于getViewAt运行在主线程,加上ANR、功耗等考虑,RemoteViews限制了加载网络图片的行为,所以,我们只能先把网络图片下载下来,然后从本地加载。
plain
override fun getViewAt(position: Int): RemoteViews {
val views = RemoteViews(context.packageName, R.layout.item_widget_menu).apply {
if (items.isNotEmpty()) {
setTextViewText(R.id.tv_widget_menu_name, items[position].name)
if (items[position].name == "添加") {
setImageViewResource(R.id.iv_widget_menu_img, R.drawable.main_widget_add)
} else {
try {
mIsImageDone[position] = false
mHandler.post {
SaveUtils.downloadImage(items[position].iconUrl).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
mIsImageDone[position] = true
}
override fun onResponse(call: Call, response: Response) {
response.body()?.let {
val inputStream: InputStream = it.byteStream()
val bitmap = BitmapFactory.decodeStream(inputStream)
mBitmap = bitmap
mIsImageDone[position] = true
}
}
})
}
//主线程等待图片下载完成
while (!mIsImageDone[position]!!) {
try {
Thread.sleep(200)
} catch (e: InterruptedException) {
e.printStackTrace()
}
}
mIsImageDone[position] = false
if (mBitmap != null) {
setImageViewBitmap(R.id.iv_widget_menu_img, mBitmap)
} else {
setImageViewResource(R.id.iv_widget_menu_img, R.mipmap.ic_logo)
}
mBitmap = null
} catch (e: Exception) {
e.printStackTrace()
}
}
this.setOnClickFillInIntent(R.id.ll_widget_menu, Intent().apply {
putExtra("widget_item_url", items[position].entryUrl)
})
}
}
return views
}
强行在主线程等200ms去下载网络图片,时间到了如果没下下来,则加载默认兜底图片。
3.5、LoadingView
而就在这等待的过程中,发现系统自带的LoadingView的图标和字体非常大,严重影响美观。
可以实现RemoteViewsFactory的getLoadingView方法,返回自定义的loading item layout来定制加载样式。
plain
override fun getLoadingView(): RemoteViews {
return RemoteViews(context.packageName, R.layout.xy_app_widget_loading_view)
}
3.6、适配深色模式
用到的色值不多(5个),分别在values和values-night中各放了一套,系统会自动选择对应模式下的颜色值。

3.7、一键添加到桌面
我们通常在做了Widget之后会在APP内进行引导设置,GIF或者是轮播图等形式,但即使这样,转化依然不高,毕竟操作的链路还是比较长的。
好在 Android 8.0 以后提供了一键添加到桌面的API------requestPinAppWidget,唯一不足是这个操作没有回调,不知道是否添加成功,毕竟有可能会因为"当前屏幕已没有空间"而导致添加失败。
plain
AppWidgetManager appWidgetManager = getSystemService(AppWidgetManager.class);;
ComponentName widgetProvider = new ComponentName(HomeTabActivity.this, XYAppWidget.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (appWidgetManager.isRequestPinAppWidgetSupported()) {
Intent pinnedWidgetCallbackIntent = new Intent(HomeTabActivity.this, XYAppWidget.class);
PendingIntent successCallback = PendingIntent.getBroadcast(HomeTabActivity.this, 0, pinnedWidgetCallbackIntent, PendingIntent.FLAG_UPDATE_CURRENT);
appWidgetManager.requestPinAppWidget(widgetProvider, null, successCallback);
}
}
4、总结
Widget通过AppWidgetProvider处理生命周期,利用RemoteViews构建视图,跨进程我们用到BroadcastReceiver,动态列表依赖RemoteViewsService加载数据。
还有加载网络图片、深色模式、一键添加等介绍,还有未提及的Android 12的适配、部分更新等,整体涉及到的细节还是比较多的,拥有一个好的使用体验并不容易。