目录
一、前言
二、Widget基本使用
2.1 AppWidgetProvider继承类对象
2.2 AppWidgetProviderInfo资源配置文件
三、定制化需求
3.1 困境
3.2 自定义小部件方案
方案一、Drawable替代绘制方案
方案二、Framework的widget目录添加自定义View文件方案
一、前言
在Android开发中,有时会遇到根据应用需求定制在launcher上显示的小部件即Widget,让用户能够不打开应用界面的前提下享受应用提供的一些功能服务,比如常见的音乐播放器,图库的图片展示,时钟及天气等等,这篇文档旨在介绍目前小部件的基本使用以及讨论现有框架的局限性,针对这些局限性,我们有哪些办法解决。
二、Widget基本使用
我们先用一个简单用例介绍一下简单的widget配置。
创建应用小部件,最起码需要两个步骤:
2.1 AppWidgetProvider继承类对象
你需要写一个类继承自AppWidgetProvider类:
class MyAppWidget: AppWidgetProvider(){
override fun update(...){}
}
一般来说,重写update这个方法即可,当添加widget或者其他操作触发更新时就会收到更新广播,接着就会调用这个方法,你在里面创建RemoteViews对象并进行配置,最后调用AppWidgetManager.updateAppWidget方法更新小部件UI,这个RemoteViews本质不是一个View,是一个专门用来跨进程传输的对象,保存小部件的布局信息,详细的以后有机会在说。除此以外,这个类还有其他一些方法,比如删除回调onDeleted, 大小调节回调onAppWidgetOPtionsChanged等等。
同时别忘了在清单文件AndroidManifest中声明这个类。别看他是个provider,但他并不是ContentProvider,而是Broadcast广播,所以需要进行声明操作:
//AndroidManifest
<receiver android:name=".MyAppWidget"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> //监听AppWidgetManager发出的更新广播,只需要声明这个即可
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/app_widget_my"/> //这个xml文件是AppWidgetProviderInfo配置文件,见下小节
</receiver>
系统服务中有一个开机启动的叫AppWidgetService的服务 具体逻辑处理在AppWidgetServiceImpl中,这个服务就是用来管理小部件的服务,被AppWidgetManager所持有,他会监听应用安装的广播:
private void registerBroadcastReceiver() {
// Register for broadcasts about package install, etc., so we can
// update the provider list.
IntentFilter packageFilter = new IntentFilter();
packageFilter.addAction(Intent.ACTION_PACKAGE_ADDED);
packageFilter.addAction(Intent.ACTION_PACKAGE_CHANGED);
packageFilter.addAction(Intent.ACTION_PACKAGE_REMOVED);
......
}
所以当应用安装后,AppWidgetServiceImpl会通过packageManager查询到应用声明的provider信息并创建AppWidgetProviderInfo对象。
需要指定两个属性:
- android:name - 指定元数据名称。使用android.appwidget.provider 将数据标识为AppWidgetProviderInfo描述符
- android:resource - 指定 AppWidgetProviderInfo资源位置
2.2 AppWidgetProviderInfo资源配置文件
这个就是上述在清单文件中声明的小部件资源文件,保存在项目的res/xml 文件夹中。用来描述应用小部件的元数据,如Widget的初始布局,更新频率,预设大小,显示位置等等。系统小部件服务就是通过读取这个创建AppWidgetProviderInfo对象的。
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="40dp" //最小宽度
android:minHeight="40dp" //最小高度
android:targetCellWidth="5" //预设宽度占用单元格数,这个单元格的宽高大小由launcher决定
android:targetCellHeight="2" //预设高度占用单元格数
android:updatePeriodMillis="86400000" // 更新频率,规定至少30分钟,所以实际使用场景下很尴尬,没卵用
android:previewImage="@drawable/preview" //在选择小部件的时候看到的预览图片
android:initialLayout="@layout/example_appwidget" //初始布局
android:configure="com.example.android.ExampleAppWidgetConfigure" //添加小部件时启动的配置Activity(可选)
android:resizeMode="horizontal|vertical" //用户手动可调整大小的方向
android:widgetCategory="home_screen"> //小部件显示的位置,主屏幕(home_screen)、锁屏(keyguard)(Android5.0以上无效)
</appwidget-provider>
配置好以上文件后,launcher应用作为小部件托管应用,从framework中拿到AppWidgetProviderInfo配置选项,提供在界面上嵌入小部件的服务,所以我们应用是不与launcher直接通信的,而是通过framework来通信交流。具体的源码流程分析下次会梳理出来。我们本次的重点是下个环节。
三、定制化需求
以上只能满足一些小部件开发的基本需求,而有时候产品设计需要我们定制一些更炫酷的UI以及交互,此时我们就遇到了困难。
3.1 困境
我们三方应用是通过将本地写好的小部件xml布局封装进RemoteViews里面根据AppWidgetId传给AppWidgetManager,然后AppWidgetManager内部的AppWidgetService根据RemoteViews创建AppWidgetProviderInfo对象并保存在内部的ArrayList中,接着就会通知小部件托管应用(Launcher),托管应用就会再通过AppWidgetManager拿到最新的一系列AppWidgetProviderInfo进行刷新操作。
这一系列流程跨越了至少三个不同的进程,所以才会使用RemoteViews这个序列化对象保存布局资源LayoutId,等待后续需要加载时再进行inflate得到View。这就导致了我们应用的自定义View是无法使用的,因为system_server进程是找不到你应用内部定义的类的,所以使用自定义View会导致报错,系统找不到该类信息。
Caused by: android.view.InflateException: Binary XML file line #7 in com.example.widgettest:layout/test_widget_layout: Error inflating class com.example.widgettest.MyImageView
Caused by: java.lang.ClassNotFoundException: com.example.widgettest.MyImageView
at java.lang.Class.classForName(Native Method)
at java.lang.Class.forName(Class.java:454)
at android.view.LayoutInflater.createView(LayoutInflater.java:819)
at android.view.LayoutInflater.createViewFromTag(LayoutInflater.java:1010)
at android.view.LayoutInflater.createViewFromTag(LayoutInflater.java:965)
at android.view.LayoutInflater.rInflate(LayoutInflater.java:1127)
at android.view.LayoutInflater.rInflateChildren(LayoutInflater.java:1088)
at android.view.LayoutInflater.inflate(LayoutInflater.java:686)
at android.view.LayoutInflater.inflate(LayoutInflater.java:538)
at android.widget.RemoteViews.inflateView(RemoteViews.java:5652)
at android.widget.RemoteViews.-$$Nest$minflateView(Unknown Source:0)
at android.widget.RemoteViews$AsyncApplyTask.doInBackground(RemoteViews.java:5779)
at android.widget.RemoteViews$AsyncApplyTask.doInBackground(RemoteViews.java:5738)
at android.os.AsyncTask$3.call(AsyncTask.java:394)
at java.util.concurrent.FutureTask.run(FutureTask.java:264)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1137)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:637)
at java.lang.Thread.run(Thread.java:1012)
可能你会很好奇,上述下划线中提到的inflate view操作,系统进程是怎么通过一个LayoutId就可以加载成一个View的,毕竟资源id只是在应用内部是唯一的,纵观整个系统则不一定唯一,而id与资源文件的对应关系保存在应用的R文件中,外部进程是如何通过id确认加载哪一个布局文件的呢?其实是应用创建RemoteViews时会在内部保存我们应用进程的ApplicationInfo对象,然后launcher需要View时,会把launcher的context传给系统进程,系统进程使用这个context和之前拿到的ApplicationInfo对象调用context.createApplicationContext方法拿到我们应用的上下文Context,就可以通过这个上下文使用资源Id访问目标应用的资源了。
RemoteViews支持的View也很有限,根据Google开发文档描述,
RemoteViews对象可以支持以下布局类:
- FrameLayout
- LinearLayout
- RelativeLayout
- GridLayout
以及以下View类(不包括后代类):
- AnalogClock
- Button
- Chronometer
- ImageButton
- ImageView
- ProgressBar
- TextView
- ViewFlipper
- ListView
- GridView
- StackView
- AdapterViewFlipper
ViewStub。
所以这个限制大大影响了我们在Widget上的发挥力,很多预想中的酷炫动效、交互等是无法实现的,比如我准备做的卫星天顶图的小部件展示,以上的View是按照道理是无法承载卫星天顶图的展示内容的,因为我们时刻监听卫星轨迹的变化,并根据卫星方位角等的参数以及手机的旋转矢量参数值进行绘制,这就是小部件开发中的最大困难点,原生小部件框架拓展性太差了。
所以面对这种不是显示固定内容而是需要根据参数动态绘制内容的情况,我们需要另辟蹊径(除非你能抛弃现有的小部件框架,自创可支持拓展的新框架,这个我们以后可以讨论讨论,有想法的同学可以分享分享)。
3.2 自定义小部件方案
方案一、Drawable替代绘制方案
考虑到我们在设计自定义View的时候,通常设计成类似于一个黑盒,外部只需要传进去一些参数,View内部在draw方法里会根据参数进行绘制,这样的话,我们可以投机取巧一下,把这个需要内部自定义绘制的View使用ImageView来替代,把这些自定义的绘制操作移到自定义Drawable里面,最后更新Widget时调用RemoteViews.setImageViewBitmap方法把自定义drawable转成Bitmap对象传过去以实现更新。
class TestDrawable: Drawable() {
@ColorInt
private var mColor = Color.BLUE
override fun draw(p0: Canvas) {
p0.drawColor(mColor)
}
fun updateColor(@ColorInt color: Int){
mColor = color
}
......
}
class TestAppWidget: AppWidgetProvider() {
override fun onUpdate(context: Context?, appWidgetManager: AppWidgetManager?, appWidgetIds: IntArray?) {
......
for (appWidgetId in appWidgetIds){
val remoteView = RemoteViews(context.packageName, R.layout.test_widget_layout)
remoteView.setImageViewBitmap(R.id.image_view, TestDrawable().toBitmap(200,200)) //图片默认是蓝色
val intent = PendingIntent.getBroadcast(context, 0, TestWidgetManager.getInternalUpdateIntent(context), PendingIntent.FLAG_MUTABLE)
remoteView.setOnClickPendingIntent(R.id.image_view, intent) //设置点击事件的intent,这里是一个自定义广播PendingIntent
appWidgetManager?.updateAppWidget(appWidgetId, remoteView)
}
}
override fun onReceive(context: Context?, intent: Intent?) {
......
//收到自定义的点击事件广播,我们可以主动去更新小部件,把新drawable传过去
if (intent?.action == TEST_UPDATE_APP_WIDGET){
val appWidgetManager = AppWidgetManager.getInstance(context)
val ids = appWidgetManager.getAppWidgetIds(ComponentName("com.example.widgettest","com.example.widgettest.TestAppWidget"))
for (id in ids){
val remoteView = RemoteViews(context.packageName, R.layout.test_widget_layout)
val drawable = TestDrawable().apply { updateColor(Color.RED) } //将图片变成红色
remoteView.setImageViewBitmap(R.id.image_view, drawable.toBitmap(200,200))
appWidgetManager.partiallyUpdateAppWidget(id, remoteView) //局部更新
}
}
}
//test_widget_layout.xml
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:android="http://schemas.android.com/apk/res/android">
<ImageView
android:id="@+id/image_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white"
android:scaleType="centerCrop"
android:clickable="true"/>
</FrameLayout>
我们给小部件的ImageView设置了一个点击事件的广播通知,当用户点击小部件的ImageView时,会从默认的蓝色变成红色,实际效果如下图:
可以发现,这个思路是可以的,我们可以根据产品需求在drawable里面进行更复杂的绘制操作,通过drawable的不断覆盖来实现交互与UI的交叉,甚至你可以在drawable里写个动画也可以。
这个方法不建议需要频繁更新的情况下使用,本身小部件通信就涉及到跨进程通信,再加上需要创建Bitmap对象造成内存消耗,频繁更新会影响到电池电量。
方案二、Framework的widget目录添加自定义View文件方案
要想真正实现更多UI、交互可能性,我们目前只能通过在framework/base/core/java/android/widget目录下把我们自定义View文件放进去,这样系统服务在inflate我们的小部件布局文件时就不会发生找不到我们自定义View的错误。
不过要注意,我们自定义View需要遵循做到以下原则:
- "高内聚,低耦合",和应用其他类要尽量解偶,不能解偶的自定义类,只能也放到framework目录下,比如自定义adapter,工具类等等
- 自定义View类必须在类上加上@RemoteView 注解,如下所示:
@RemoteView
public class MyImageView extends ImageView { ...... }
- 路径一致。自己应用的自定义View的路径需要和放到系统里面的自定义View路径一致,也就是说我们需要在自己的应用项目java目录下新增一样的文件夹路径:framework/base/core/java/android/widget ,然后把自定义View放进去即可。
在自定义View里面可以添加数据处理逻辑,视图切换动画等等。
现在系统能够识别出我们的自定义View了,但我们在自己的应用里是无法直接把数据注入到自定义View的,毕竟更新时只有LayoutId,没有View,我们无法通过findViewById拿到View对象再把数据注进去,那该怎么办呢?RemoteViews也给我们提供了一系列方法:
//RemoteView.java
public class RemoteViews implements Parcelable, Filter {
public void setBoolean(int viewId, String methodName, boolean value) {...}
public void setColor(int viewId, @NonNull String methodName, int colorResource) {...}
public void setString(int viewId, String methodName, String value) {...}
public void setBundle(int viewId, String methodName, Bundle value) {...}
......
}
从参数上想必你大概可以猜出来是怎么注入进去的吧,没错,就是内部通过反射的方式把数据传进去:
//RemoteView.BaseReflectionAction.apply(...)
getMethod(view, this.methodName, param, false /* async */).invoke(view, value);
所以,你可以在需要主动或者被动触发更新时,在创建RemoteViews对象时,调用上述方法即可,最后调用AppWidgetManager.updateAppWidget更新方法或者AppWidgetManager.partiallyUpdateAppWidget局部更新方法去刷新小部件的UI。
当然,如果你想要自定义一些集合展示并且自定义Adapter的话,要更复杂一些,因为原生框架内部已经封装好了类似适配器的存在,已经写死了,要修改的地方要多一些。这里就不展开讲了,以前做过类似的图库小部件,可以实现自定义切换动画,因为需求需要修改原生的adapter内部的一些缓存策略,所以改过,是一个比较痛苦的回忆,哈哈。
以上,就是实现小部件自定义绘制的两个方案,一个是自定义Drawable,一个是自定义View,前者虽然不用修改源码,但仍然会有许多局限性,后者虽然比较自由,能实现更酷炫的自定义UI和交互,但你必须拥有修改源码的能力,比如手机系统研发人员。