Widget开发实践指南

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)
        }
    }
  1. 先构建PendingIntent事件,用于启动Activity。
  2. 通过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的适配、部分更新等,整体涉及到的细节还是比较多的,拥有一个好的使用体验并不容易。

5、相关文档

developer.android.com/develop/ui/...

相关推荐
Bl_a_ck13 分钟前
【React】Craco 简介
开发语言·前端·react.js·typescript·前端框架
橙子199110161 小时前
Kotlin 中的作用域函数
android·开发语言·kotlin
zimoyin1 小时前
Kotlin 懒初始化值
android·开发语言·kotlin
augenstern4161 小时前
webpack重构优化
前端·webpack·重构
海拥✘1 小时前
CodeBuddy终极测评:中国版Cursor的开发革命(含安装指南+HTML游戏实战)
前端·游戏·html
寧笙(Lycode)2 小时前
React系列——HOC高阶组件的封装与使用
前端·react.js·前端框架
asqq82 小时前
CSS 中的 ::before 和 ::after 伪元素
前端·css
枣伊吕波2 小时前
第六节第二部分:抽象类的应用-模板方法设计模式
android·java·设计模式
萧然CS2 小时前
使用ADB命令操作Android的apk/aab包
android·adb
拖孩2 小时前
【Nova UI】十五、打造组件库之滚动条组件(上):滚动条组件的起步与进阶
前端·javascript·css·vue.js·ui组件库