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。

经验之谈: 我曾经遇到一个第三方Widget,它的配置Activity在横竖屏切换时会重新创建,导致appWidgetId丢失。后来我在Launcher里加了超时保护——如果配置Activity超过30秒没返回,就自动取消并释放ID。

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有三个值:horizontalverticalnone。我建议除非你的Widget真的不能缩放,否则至少支持水平缩放。用户喜欢自由调整布局。

8.3.2 Launcher端的resize实现

Launcher收到resize手势后,需要做这几步:

  1. 计算新的单元格范围(startX, startY, spanX, spanY)
  2. 检查是否超出最小尺寸限制
  3. 更新CellLayout中的占位
  4. 调用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没有正确读取minResizeWidthminResizeHeight

正确的做法是:

  • AppWidgetProviderInfo中读取minResizeWidthminResizeHeight
  • 如果没声明,就用minWidthminHeight作为下限
  • 把dp值转换成像素,再除以单元格大小,得到最小占格数
核心原则: Launcher永远不能把Widget缩到比它声明的最小尺寸还小。否则Widget内容会变形或截断。

8.4 知识体系总览

下面这张图梳理了本章的核心逻辑——从用户拖拽Widget到桌面的完整流程:

Widget添加到桌面的完整流程 1. 用户拖拽Widget 从Widget列表拖到桌面 2. 检查配置Activity configure != null ? 有配置 无配置 3a. 启动配置Activity 等待用户配置完成 3b. 直接创建View 调用createView() 4. 绑定AppWidgetHostView 添加到CellLayout并显示 5. 用户resize(可选) 更新spanX/spanY,调用updateAppWidgetSize() 关键组件 • AppWidgetHost:管理生命周期 • AppWidgetHostView:显示载体 • AppWidgetManager:系统服务 • RemoteViews:跨进程UI • CellLayout:桌面网格布局

从这张图可以清楚看到,整个流程是线性的——拖拽、判断配置、创建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