8、桌面小部件(AppWidget):AppWidgetHost与AppWidgetHostView的绑定、Widget的配置Activity、Widget的resize机制
桌面小部件,说白了就是App放在桌面上的一个“快捷窗口”。用户不用打开应用,直接在桌面上就能看到信息、进行操作。我刚开始做Launcher开发时,觉得这玩意儿不就是个RemoteViews嘛,后来踩了不少坑才明白——真正复杂的不是小部件本身,而是Launcher怎么去“托管”它。
这一章,我们就来聊聊Launcher作为宿主,怎么绑定小部件、怎么处理配置界面、怎么支持用户拖拽调整大小。嗯,内容有点多,但都是实战干货。
8.1 AppWidgetHost与AppWidgetHostView的绑定
Launcher要显示一个小部件,得先通过AppWidgetHost向系统申请一个“绑定”。系统会返回一个AppWidgetHostView,这个View就是小部件在桌面上的实际载体。
8.1.1 创建AppWidgetHost
每个Launcher进程只需要一个AppWidgetHost实例。我习惯在Application或Launcher的ApplicationContext中创建它:
public class LauncherApp extends Application {
private static final int APPWIDGET_HOST_ID = 1024;
private AppWidgetHost mAppWidgetHost;
@Override
public void onCreate() {
super.onCreate();
mAppWidgetHost = new AppWidgetHost(this, APPWIDGET_HOST_ID);
}
public AppWidgetHost getAppWidgetHost() {
return mAppWidgetHost;
}
}
这里有个坑——hostId必须是唯一的。我曾经在一个项目里看到两个Launcher进程用了同一个ID,结果小部件绑定全乱了。建议用一个固定的、不与其他应用冲突的整数。
8.1.2 绑定并创建AppWidgetHostView
当用户从Widget列表里拖出一个部件到桌面时,Launcher需要调用createView方法:
AppWidgetHostView hostView = mAppWidgetHost.createView(
context,
appWidgetId,
appWidgetInfo
);
这个方法内部会做几件事:
- 通过
AppWidgetManager获取RemoteViews - 将RemoteViews inflate成一个真正的View树
- 绑定各种点击事件和PendingIntent
嗯,这里要注意——createView是同步操作,但RemoteViews的加载可能涉及跨进程通信。我建议在非UI线程做预加载,或者至少保证调用时主线程不卡顿。
8.1.3 生命周期管理
Launcher作为Host,必须管理好小部件的生命周期。关键方法就三个:
| 方法 | 作用 | 调用时机 |
|---|---|---|
startListening() |
开始监听小部件更新广播 | Launcher可见时 |
stopListening() |
停止监听,释放资源 | Launcher不可见时 |
deleteAppWidgetId() |
删除小部件ID,释放系统资源 | 用户移除小部件时 |
我见过不少Launcher在onPause里忘了调stopListening,结果小部件还在后台频繁更新,白白耗电。你想想看,用户把Launcher切到后台了,小部件还刷个什么劲?
deleteAppWidgetId,再移除对应的CellLayout。顺序反了会导致系统资源泄漏。
8.2 Widget的配置Activity
有些小部件在添加到桌面之前,需要用户做一些配置——比如天气Widget要选城市、时钟Widget要选时区。这个配置界面就是AppWidgetProviderInfo里声明的configure属性。
8.2.1 配置Activity的启动流程
当用户从Widget列表选择一个需要配置的小部件时,Launcher不能直接创建View,而是要先启动配置Activity:
// 伪代码,实际在Launcher的Widget添加逻辑中
Intent intent = new Intent(Intent.ACTION_MAIN);
intent.setComponent(appWidgetInfo.configure);
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
startActivityForResult(intent, REQUEST_CREATE_APPWIDGET);
配置Activity完成后,必须通过setResult返回结果:
// 配置Activity中
Intent resultValue = new Intent();
resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
setResult(RESULT_OK, resultValue);
finish();
如果用户取消了配置,要返回RESULT_CANCELED。Launcher收到取消结果后,需要删除之前申请的appWidgetId。
8.2.2 配置失败的处理
不是所有Widget都会乖乖返回结果。我建议Launcher做以下兜底:
- 监听配置Activity的onActivityResult,处理RESULT_CANCELED
- 如果配置Activity崩溃或被杀,系统会回调
onAppWidgetRemoved - 配置超时后,主动释放appWidgetId
8.3 Widget的resize机制
Android 3.1开始支持小部件缩放,但真正好用是从Android 4.0开始的。Launcher需要处理两种resize模式:横向和纵向。
8.3.1 支持resize的声明
Widget在appwidget-provider里声明支持方向:
<appwidget-provider
android:minWidth="250dp"
android:minHeight="110dp"
android:resizeMode="horizontal|vertical"
android:minResizeWidth="180dp"
android:minResizeHeight="80dp" />
resizeMode有三个值:horizontal、vertical、none。我建议除非你的Widget真的不能缩放,否则至少支持水平缩放。用户喜欢自由调整布局。
8.3.2 Launcher端的resize实现
Launcher收到resize手势后,需要做这几步:
- 计算新的单元格范围(startX, startY, spanX, spanY)
- 检查是否超出最小尺寸限制
- 更新CellLayout中的占位
- 调用
AppWidgetHostView.updateAppWidgetSize()
核心代码大概长这样:
// 计算新尺寸后
Bundle newOptions = new Bundle();
newOptions.putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, newWidth);
newOptions.putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, newHeight);
newOptions.putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, newWidth);
newOptions.putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, newHeight);
mAppWidgetHostView.updateAppWidgetSize(newOptions, newWidth, newHeight);
嗯,这里有个细节——updateAppWidgetSize会触发Widget的onAppWidgetOptionsChanged回调。Widget开发者可以在那里重新布局,适配新尺寸。
8.3.3 尺寸约束与边界处理
我在项目中遇到过一个问题:用户把Widget拖到最小,结果内容显示不全。后来发现是Launcher没有正确读取minResizeWidth和minResizeHeight。
正确的做法是:
- 从
AppWidgetProviderInfo中读取minResizeWidth和minResizeHeight - 如果没声明,就用
minWidth和minHeight作为下限 - 把dp值转换成像素,再除以单元格大小,得到最小占格数
8.4 知识体系总览
下面这张图梳理了本章的核心逻辑——从用户拖拽Widget到桌面的完整流程:
从这张图可以清楚看到,整个流程是线性的——拖拽、判断配置、创建View、绑定、resize。每一步都有对应的API和注意事项。
8.5 避坑指南
最后,我把自己踩过的坑整理一下,希望能帮你少走弯路:
- appWidgetId泄漏:每次申请ID都要确保最终被释放。我曾经在配置Activity取消时忘了删ID,结果系统里积攒了几十个废弃ID。
- RemoteViews更新不及时:如果Widget更新了布局,Launcher需要重新inflate。我建议在
onAppWidgetUpdate回调里重新创建HostView。 - resize时闪烁:更新尺寸时如果直接改CellLayout的占格,会导致界面跳动。正确的做法是先计算新位置,再动画过渡。
- 配置Activity被杀死:如果配置Activity因为内存不足被回收,Launcher会收到
RESULT_CANCELED。记得在onSaveInstanceState里保存appWidgetId。
嗯,这一章的内容就到这里。Widget的绑定和resize是Launcher开发中最容易出问题的环节之一,但只要理解了Host和HostView的关系,剩下的就是细心处理边界情况了。
公众号:蓝海资料掘金营,微信deep3321