Android 上的 AppWidget 开发

Android

桌面小部件(AppWidget),是 Android 特有的一个特性,而且早在 API 3 就有了,貌似在 2012 年那时候还挺流行的,记得当时自己经常把一堆“新闻”“联系人”“短信”什么的都往桌面上丢,再加上 HTC 当年的设计还挺好看的,有一种很有逼格的感觉。。。

不过,当我第一次看到有人提出“掌重能不能有个课表插件”的时候,我的第一反应是“这年头真有人用这东西?(黑人问号)”。然而,提出这个需求的同学还不止一个。诚然现在我的手机和同学的手机,主屏幕上除了一个时钟基本上没有其他 AppWidget 了,甚至还有些厂商的定制 UI 直接把主屏幕做成了应用程序列表(就像 iOS 一样),根本没有给 AppWidget 留下位置。

既然有这个需求,那就实现一下吧!

打开 Google 的 AppWidget 开发指引 ,配图就给人一种十分有年代的感觉。

貌似那年头还有种叫动态壁纸的东西

声明小部件

第一步就是在 AndroidManifest.xml 里声明你的 APP 会提供桌面小部件,还需要提供一堆相关信息,这些在 开发指引 里都有模板代码,yank & pasty 就 OK。

值得一提的是minWidthminHeight都是使用 dp 而不是“格数”进行设定的,Google 提供了一份 转换表 帮助开发者进行这个转换,当然如果你使用 Android Studio 的 New -> Widget -> AppWidget 创建桌面小部件,Android Studio 会有图形化的功能帮助你进行格数与 dp 的换算。

布局

作为 RemoteViews ,AppWidget 的 UI 设计有相当大的限制。

  • 支持的 Layout: FrameLayout, LinearLayout, RelativeLayout, GridLayout
  • 支持的 View: AnalogClock, Button, Chronometer, ImageButton, ImageView, ProgressBar, TextView, ViewFlipper, ListView, GridView, StackView, AdapterViewFlipper
  • ViewStub

如果是静态的自定义 View ,也可以使用 GLGJing 在 Android 开发之实时更新 App Widget 中提到的这个方法:(实质上是先将 View 在一块画布上绘制成 Bitmap ,然后设置到 RemoteViews 的 ImageView 里去)

RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.widget);

MyCustomView customView = new MyCustomView(context);
customView.measure(width, height);
customView.layout(0, 0, width, height);
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
customView.draw(new Canvas(bitmap));
remoteViews.setImageViewBitmap(R.id.bitmap, bitmap);

杂项

  • 用 RemoteViews#setOnClickPendingIntent + PendingIntent 处理点击事件
  • 除了 RemoteViews#setTextView 和 RemoteViews#setImageView 这种指明 View 类型的方法,还可以使用 setInt, setDouble, setBoolean 这套 set 方法间接调用指定 View 的方法,这使动态调节更加方便了。

ListView

ListView 是一个坑。

虽然在 Layout 里写起来和普通的 ListView 差不多,但是由于 ListView 总是需要一个 Adapter ,所以使用上还是有相当大的区别。

为了能有个东西能在程序被关闭的情况下担当起 Adapter 的职责,让用户可以顺利地滑动在 AppWidget 里的 ListView 而不需要唤起你的 APP ,我们需要使用 RemoteViewsService.RemoteViewsFactory ,先来讲一下什么是 RemoteViewsService。

RemoteViewsService

这是一个 Service ,它隐藏了 Service 的所有方法,只有一个方法需要你来实现:

@Override
public RemoteViewsFactory onGetViewFactory(Intent intent) {
    return new Factory();
}

正常情况下,在 APP 运行时,这个服务只会启动一次,调用一下这个方法,然后系统很快就 Destroy 掉了这个 Service ,也就是说这个 Service 的作用基本上就是为系统返回一个 RemoteViewsFactory ,基本上不太可能通过这个 Service 来控制 AppWidget 的更新。

RemoteViewsFactory

这是 RemoteViewsService 的内部接口。

看到这个接口的定义,你就会有种似曾相识的感觉了,这不就是 Adapter 么。。。

然而,比起我们熟悉的 ArrayAdapter ,它少了一个很重要的方法: notifyDataSetChanged

实现这个接口,并且在 RemoteViewsService#onGetViewFactory() 返回这个类的实例,然后通过 RemoteViews.setRemoteViewsAdapter 将这个 RemoteViewsFactory 与 ListView 进行关系上的“连接”。

更新:异步?同步?

然后就是更新的问题。

如果没有使用 ListView 这类的基于 Collection 的 View ,直接按照 Google 那个最简单的示例调用 AppWidgetManager.updateAppWidget() 就 OK 了,反正就是把 UI 重绘一遍,一切都在 AppWidgetProvider 这个广播接收器里完成因此部分信息也可以直接往 Intent 里丢。

但是如果使用了 ListView ,除了重绘控件之外还得通知 RemoteViewsFactory 重新加载数据,我们得和 RemoteViewsFactory 通信,然而系统在拿走 RemoteViewsFactory 就马上 Destory 了 RemoteViewsService ,因此通过 RemoteViewsService 请求 RemoteViewsFactory 进行更新是不太可能的,我们得想其他方法进行通信。Google 提供了一张图表示 RemoteViewsFactory 各个方法的调用方式:

又看到了一个似曾相识的方法:AppWidgetManager.notifyAppWidgetViewDataChanged()

看起来和我们熟悉的 ArrayAdapter#notifyDataSetChanged() 有些相似,然而这种情况却略有不同。

我们平时使用 Adapter ,Adapter 与数据基本上分离,我们一直持有 Adapter 的引用,通过回调拿取数据,数据变更之后只需要给 Adapter 添加数据,然后调用它的 notifyDataSetChanged() 就好。但是在这种情况下,我们并没有持有 RemoteViewsFactory 的引用,不大可能直接往里面添加数据(这不得不让我们把数据获取过程放在 RemoteViewsFactory 里面),在创建之后也只有一个 onDataSetChanged() 能被我们调用,这个方法被调用之后会马上调用其余的获取数据方法,这种情况下做异步加载就很不容易了。

最后还是得用同步。。。

为了避免造成 UI 线程卡顿,把整个 RemoteViewsService 放到一个单独的后台进程里,由于停顿 5 - 20 秒也可能导致后台进程发生 ANR ,所以使用需要时间比较少的文件缓存机制,APP 先获取到数据,缓存到文件里,然后通知 AppWidget 进行加载。

最终,刷新总算是可以用了。。。。

点击事件

一般来说,一个 ListView 都会至少有个点击事件,不能与用户交互的 UI 意义不大。

根据官方文档,增加点击事件是通过 PendingIntent 来完成的,然而这是对于普通控件。对于集合控件,比如 ListView 和 StackView ,如果你这样做的话,系统将会很肯定地告诉你:不能给 Collection View 添加 PendingIntent 。在文档中,[setOnClickPendingIntent(int, android.app.PendingIntent)](https://developer.android.com/reference/android/widget/RemoteViews.html#setOnClickPendingIntent\(int, android.app.PendingIntent)) 下面也有这样的一句话

When setting the on-click action of items within collections (eg. ListView, StackView etc.), this method will not work. Instead, use RemoteViews#setPendingIntentTemplate(int, PendingIntent) in conjunction with RemoteViews#setOnClickFillInIntent(int, Intent).

也就是说要用到列表的点击事件的话,需要使用 setPendingIntentTempletesetOnClickFillInIntent ,实际上这就是一个拆分与合并 Intent 的过程。把每个选项的固定值放在 PendingIntent 里,比如 action ,关联的包名和类名。把每个选项的特定值放在一个 Intent 里,比如选项的顺序以及它关联的数据等等,Android 会将这两个 Intent 直接拼合起来。

代码大概就是这样的:

在 RemoteViewsFactory 中,对于每个列表元素:

Intent fillInIntent = new Intent();
intent.putExtra(order);
views.setOnClickFillInIntent(R.id.list_item, fillInIntent);

在 AppWidgetProvider 中,对于 onUpdate 方法返回的 RemoteViews :

Intent itemClickIntent = new Intent(context, MainActivity.class);
itemClickIntent.setAction(context.getResources().getString(R.string.action_appwidget_item_on_click));
views.setPendingIntentTemplate(R.id.list, PendingIntent.getActivity(context, 0, itemClickIntent, PendingIntent.FLAG_CANCEL_CURRENT));

这样就设置好了每一个列表项的点击事件。

总结

这篇文章是在遇到很多坑之后写的(有些地方实在糟糕所以 push 代码之前我都先把一些提交给 rebase 掉了),这方面近几年看起来都快被放弃了,支持复杂 AppWidget 的 APP 也不多,搜索出来的很多代码都还是 Eclipse 时代的。。。

不过总算是个“能用”的方案吧,但是肯定不是最好的方案,先记下来,以后慢慢优化吧。。。

以上。