前言:
最近在项目里接入了一版桌面小组件,一开始以为只是 Flutter 里加一个组件,然后把它显示到桌面上就可以了,真正做下来才发现,桌面小组件并不是 Flutter 页面的一部分。
Android 端走的是系统 AppWidget,iOS 端走的是 WidgetKit。Flutter 只负责把数据写出去、通知系统刷新、接收点击回调,真正展示到桌面上的 UI 还是要分别在 Android 和 iOS 原生侧实现。
所以这篇文章就结合当前这个 Demo,聊一聊在 Flutter 项目里如何使用 home_widget 开发桌面小组件,以及 Android、iOS 两端分别需要做哪些工作。文章不单独讲 API,而是偏项目落地,重点说清楚实现链路、平台差异和踩坑点。
正文:
这个 Demo 中的桌面小组件实现,主要从下面几个维度来理解:
-
Flutter 桌面小组件的整体思路
-
home_widget插件做了什么 -
Flutter 侧如何写入数据和刷新小组件
-
Android 端需要做哪些工作
-
iOS 端需要做哪些工作
-
点击小组件如何跳回 App 首页
-
实现过程中遇到的问题和坑
-
桌面小组件开发的推荐标准
桌面小组件的基本思想
Flutter 桌面小组件和普通 Flutter 页面不一样。
普通页面是:
-
Flutter 构建 Widget
-
Flutter 管理状态
-
Flutter 负责渲染
-
Flutter 处理点击和跳转
桌面小组件则是:
-
Flutter 只负责准备数据
-
Android 使用
RemoteViews渲染 AppWidget -
iOS 使用
WidgetKit + SwiftUI渲染 Widget -
系统桌面负责展示、缓存和刷新
-
点击事件通过插件桥接回 Flutter
也就是说,桌面小组件不是把 Flutter Widget 直接放到桌面上,而是把数据写到一个平台可读取的共享区域,再由原生小组件读取数据并渲染。
这套思路最大的特点是:
-
UI 必须分平台实现
-
数据需要跨进程共享
-
刷新不是实时的,受系统调度影响
-
点击小组件需要通过 URL 或 Intent 回到 App
-
iOS 和 Android 的限制完全不同
使用的插件
项目里使用的是:
yaml
home_widget: ^0.9.2
这个插件的定位很明确:它不是帮你用 Flutter 画一个桌面小组件,而是帮 Flutter 和系统小组件之间建立桥接。
它主要做了几件事:
-
提供
saveWidgetData,把 Flutter 数据写入平台侧可读取的存储 -
提供
updateWidget,通知 Android AppWidget 或 iOS WidgetKit 刷新 -
提供
initiallyLaunchedFromHomeWidget,判断 App 是否由小组件冷启动 -
提供
widgetClicked,监听 App 运行中由小组件触发的点击 -
Android 侧提供
HomeWidgetProvider和HomeWidgetLaunchIntent等辅助能力 -
iOS 侧配合 App Group,让 App 和 Widget Extension 共享数据
所以可以把 home_widget 理解成一个"数据桥 + 刷新桥 + 点击桥"。
它不负责:
-
自动生成 Android 小组件布局
-
自动生成 iOS WidgetKit 页面
-
自动处理所有系统缓存
-
自动解决小组件尺寸适配
-
自动把 Flutter Widget 转成原生 Widget
这也是很多同学刚接入时容易误解的地方。
Flutter 侧完整流程
Flutter 侧主要做三件事:
-
初始化小组件服务
-
保存小组件数据
-
通知系统刷新小组件
-
处理点击小组件后的跳转
Demo 里封装了一个 DemoHomeWidgetService:
dart
class DemoHomeWidgetService {
DemoHomeWidgetService._();
static const String _androidProviderName =
'com.example.flutter_home_widget_demo.widget.DemoHomeWidgetProvider';
static const String _iosWidgetName = 'DemoHomeWidget';
static const String _appGroupId = 'group.com.example.flutterHomeWidgetDemo';
static const String _titleKey = 'widget_title';
static const String _subtitleKey = 'widget_subtitle';
static const String _descriptionKey = 'widget_description';
static const String _actionKey = 'widget_action';
}
这些 key 很重要。Flutter 写入什么 key,Android 和 iOS 就要用同样的 key 去读取。
保存数据时,使用:
dart
await Future.wait([
HomeWidget.saveWidgetData<String>(_titleKey, title),
HomeWidget.saveWidgetData<String>(_subtitleKey, subtitle),
HomeWidget.saveWidgetData<String>(_descriptionKey, description),
HomeWidget.saveWidgetData<String>(_actionKey, action),
]);
保存完成后,再通知平台刷新:
dart
if (Platform.isIOS) {
await HomeWidget.updateWidget(iOSName: _iosWidgetName);
} else {
await HomeWidget.updateWidget(
name: _androidProviderName,
qualifiedAndroidName: _androidProviderName,
);
}
这里要注意,iOSName 对应的是 iOS Widget 的 kind,Android 的 qualifiedAndroidName 对应的是原生 AppWidgetProvider 的完整类名。
如果名字不一致,Flutter 调用了刷新,系统侧也不会更新到你想要的那个小组件。
Flutter 侧初始化时机
小组件初始化通常不建议阻塞 App 首帧。
项目里是在 runApp 之后,首帧回调里初始化:
dart
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(DemoHomeWidgetService.initialize());
});
这样做的好处是:
-
不影响 App 启动速度
-
小组件数据刷新可以异步执行
-
点击回调也能在 App 启动后注册
初始化里主要做:
dart
if (Platform.isIOS) {
await HomeWidget.setAppGroupId(_appGroupId);
}
await update();
await _handleInitialLaunch();
_widgetClickSubscription ??= HomeWidget.widgetClicked.listen(_handleClick);
iOS 必须先设置 App Group。因为 iOS App 和 Widget Extension 是两个不同 target,数据共享要通过 App Group。
Android 端需要做的工作
Android 端主要包含四部分:
-
新建
AppWidgetProvider -
编写
RemoteViews布局 -
编写
appwidget-provider配置 -
在
AndroidManifest.xml注册 receiver
项目里的 Provider 是:
kotlin
class DemoHomeWidgetProvider : HomeWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
widgetData: SharedPreferences,
) {
appWidgetIds.forEach { widgetId ->
val views = RemoteViews(context.packageName, R.layout.demo_home_widget).apply {
val openAppIntent = HomeWidgetLaunchIntent.getActivity(
context,
MainActivity::class.java,
Uri.parse("demo://home-widget/open"),
)
setOnClickPendingIntent(R.id.widget_root, openAppIntent)
setOnClickPendingIntent(R.id.widget_action, openAppIntent)
setTextViewText(
R.id.widget_subtitle,
widgetData.getString("widget_subtitle", "随时记录闪念"),
)
}
appWidgetManager.updateAppWidget(widgetId, views)
}
}
}
这里有几个重点:
-
HomeWidgetProvider继承自插件提供的 Provider -
widgetData本质上是插件帮你桥接过来的SharedPreferences -
RemoteViews只能操作系统支持的小组件 View -
点击事件需要通过
PendingIntent回到 App -
每个
appWidgetId都需要调用updateAppWidget
Android 小组件布局
Android 的小组件布局不是 Flutter Widget,也不是普通 Activity 里的完整 Android View。
它使用的是 RemoteViews,支持的控件有限。项目里当前使用的是一个小尺寸方形组件:
xml
<LinearLayout
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="5dp"
android:background="@drawable/demo_widget_background"
android:orientation="vertical"
android:padding="11dp">
<TextView
android:layout_width="26dp"
android:layout_height="26dp"
android:background="@drawable/demo_widget_logo_background"
android:gravity="center"
android:text="W"
android:textColor="#FF7A45"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1"
android:textColor="#FFFFFF"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_description"
android:layout_width="match_parent"
android:layout_height="32dp"
android:maxLines="2"
android:minLines="2"
android:textColor="#E8EBFF"
android:textSize="11sp" />
</LinearLayout>
布局这块要特别克制。因为 Android 桌面小组件由 Launcher 承载,不同厂商、不同桌面、不同系统版本对 RemoteViews 的支持和裁剪都不完全一样。
Android 小组件尺寸配置
小组件尺寸在 res/xml/demo_home_widget.xml 中配置:
xml
<appwidget-provider
android:initialLayout="@layout/demo_home_widget"
android:minWidth="110dp"
android:minHeight="110dp"
android:previewLayout="@layout/demo_home_widget"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen" />
这里的核心字段是:
-
initialLayout:小组件初始布局 -
previewLayout:小组件预览布局 -
minWidth/minHeight:最小尺寸 -
targetCellWidth/targetCellHeight:推荐桌面网格尺寸 -
resizeMode:是否允许调整大小 -
updatePeriodMillis:系统定时刷新间隔
如果想做类似 iOS small 的方形卡片,Android 侧通常可以用 2x2。
AndroidManifest 注册
Android 还需要在 AndroidManifest.xml 里注册 receiver:
xml
<receiver
android:name=".widget.DemoHomeWidgetProvider"
android:exported="true"
android:label="测试小组件">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/demo_home_widget" />
</receiver>
没有这一步,系统桌面不会识别到这个小组件。
iOS 端需要做的工作
iOS 端和 Android 完全不一样。
iOS 需要创建一个 Widget Extension,然后使用 WidgetKit + SwiftUI 写 Widget 页面。
核心结构通常是:
-
TimelineEntry -
TimelineProvider -
Widget View -
Widget配置 -
App Group
Demo 里定义了一个 Entry:
swift
struct DemoEntry: TimelineEntry {
let date: Date
let title: String
let subtitle: String
let description: String
let action: String
}
Provider 负责从 App Group 里读取 Flutter 写入的数据:
swift
private let appGroupId = "group.com.example.flutterHomeWidgetDemo"
struct DemoProvider: TimelineProvider {
private func loadEntry() -> DemoEntry {
let defaults = UserDefaults(suiteName: appGroupId)
return DemoEntry(
date: Date(),
title: defaults?.string(forKey: "widget_title") ?? DemoEntry.defaultEntry.title,
subtitle: defaults?.string(forKey: "widget_subtitle") ?? DemoEntry.defaultEntry.subtitle,
description: defaults?.string(forKey: "widget_description") ?? DemoEntry.defaultEntry.description,
action: defaults?.string(forKey: "widget_action") ?? DemoEntry.defaultEntry.action
)
}
}
这里最关键的是:
swift
UserDefaults(suiteName: appGroupId)
它必须和 Flutter 侧:
dart
HomeWidget.setAppGroupId(_appGroupId);
使用同一个 App Group。
iOS Widget View
iOS 使用 SwiftUI 写小组件:
swift
struct DemoHomeWidgetView: View {
let entry: DemoEntry
var body: some View {
smallContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(legacyWidgetBackground)
.unredacted()
.demoWidgetBackground { widgetBackground }
.widgetURL(URL(string: "demo://home-widget/open"))
}
}
这里有几个点需要注意:
-
widgetURL用来设置点击小组件后的 URL -
unredacted()可以避免内容被系统显示成 placeholder 灰条 -
iOS 17 以后需要使用
containerBackground(for: .widget) -
如果只需要 small,就只声明
.systemSmall
这个 Demo 只支持 small:
swift
@main
struct DemoHomeWidget: Widget {
let kind = "DemoHomeWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: DemoProvider()) { entry in
DemoHomeWidgetView(entry: entry)
}
.configurationDisplayName("测试小组件")
.description("用于演示 Flutter 桌面小组件的数据刷新。")
.supportedFamilies([.systemSmall])
.demoContentMarginsDisabled()
}
}
kind 要和 Flutter 刷新时的 iOSName 对上:
dart
HomeWidget.updateWidget(iOSName: 'DemoHomeWidget');
iOS 17 的背景适配
iOS 17 之后,如果 Widget 没有使用新的背景 API,经常会看到类似:
text
Please adopt containerBackground API
解决方式是:
swift
private extension View {
@ViewBuilder
func demoWidgetBackground<Background: View>(
@ViewBuilder _ background: () -> Background
) -> some View {
if #available(iOSApplicationExtension 17.0, *) {
containerBackground(for: .widget, content: background)
} else {
self
}
}
}
同时 iOS 14-16 仍然需要普通 .background 兜底。
如果需要去掉 iOS 17 默认的小组件内容边距,可以加:
swift
private extension WidgetConfiguration {
func demoContentMarginsDisabled() -> some WidgetConfiguration {
if #available(iOSApplicationExtension 17.0, *) {
return contentMarginsDisabled()
} else {
return self
}
}
}
点击小组件跳回 App
小组件点击跳回 App,核心是统一一个 URL:
text
demo://home-widget/open
Android 端通过:
kotlin
HomeWidgetLaunchIntent.getActivity(
context,
MainActivity::class.java,
Uri.parse("demo://home-widget/open"),
)
iOS 端通过:
swift
.widgetURL(URL(string: "demo://home-widget/open"))
Flutter 端统一处理:
dart
static void _handleClick(Uri? uri) {
if (uri == null) return;
if (!DemoHomeWidgetDeepLink.matches(uri)) return;
appRouter.go(DemoHomeWidgetDeepLink.resolveLaunchRoute());
}
这里最好不要直接把 demo://home-widget/open 交给 go_router 解析。
因为冷启动时,go_router 可能会把它当成一个普通路由路径,导致进入 404 或找不到页面。更稳的做法是先识别这是小组件 deep link,再手动跳转到 App 首页或开屏页。
实现过程中遇到的问题和坑
- 坑1:以为 Flutter Widget 可以直接显示到桌面
这是最常见的误解。Flutter 桌面小组件不是 Flutter UI 外挂到桌面,而是 Android/iOS 原生系统小组件。
Flutter 只负责数据和刷新。
- 坑2:Android RemoteViews 支持的 View 很有限
Android 小组件布局不能随便写。比如有些普通 View 在 AppWidget 里不支持,可能直接出现:
text
载入窗口小部件时出现问题
项目里就遇到过类似情况。解决思路是:
-
尽量使用
LinearLayout -
尽量使用
TextView -
背景用 drawable
-
少用复杂控件
-
不要随便引入普通页面里的自定义 View
-
坑3:Android 小组件尺寸需要反复调
Android 桌面小组件受 Launcher、网格、设备密度影响很大。
同一个 2x2,在不同手机上实际展示区域可能不完全一样。字号、间距、按钮高度都需要按真实设备调。
比如描述文案要显示两行,就不能只写:
xml
android:maxLines="2"
还要给它足够高度:
xml
android:layout_height="32dp"
android:minLines="2"
android:maxLines="2"
否则系统实际测量后可能仍然只显示一行。
- 坑4:Android 更新时引用了不存在的控件
如果布局里删掉了一个 id,但 Provider 里还在:
kotlin
setTextViewText(R.id.widget_title, ...)
小组件可能直接加载失败。
所以每次改 layout,都要同步检查 Provider 里的 R.id.xxx。
- 坑5:iOS App Group 不一致会读不到数据
iOS 上 App 和 Widget Extension 是两个 target。
Flutter 写入数据后,Widget 读不到,最常见原因就是 App Group 配错:
-
Flutter 侧
setAppGroupId -
App target entitlements
-
Widget target entitlements
-
Apple Developer 后台能力
这几个地方必须一致。
- 坑6:iOS 17 必须适配 containerBackground
iOS 17 之后 WidgetKit 对背景处理更严格。
如果没有适配,可能出现系统提示:
text
Please adopt containerBackground API
甚至导致正常内容没有按预期展示。
- 坑7:iOS 内容被显示成灰条
WidgetKit 在 placeholder、预览或某些刷新阶段,可能把内容 redacted 成灰条。
如果希望展示真实默认内容,可以在内容层加:
swift
.unredacted()
- 坑8:iOS Widget 缓存非常重
iOS 的 Widget catalog 和桌面 Widget 都有缓存。
改了 Widget 代码后,如果真机上还是旧样式,可能不是代码没生效,而是系统还在用旧缓存。
建议排查顺序:
-
删除桌面旧小组件
-
重新添加小组件
-
卸载 App 后重新安装
-
必要时重启手机
- 坑9:只支持 small 时不要声明其它尺寸
如果业务上只需要 small,就只写:
swift
.supportedFamilies([.systemSmall])
不要为了让系统展示多个卡片而声明 medium、large。声明了就意味着系统会允许用户添加这些尺寸,后续还要维护对应布局。
- 坑10:点击 URL 要和路由系统隔离
小组件点击 URL 最好先走一层解析器,比如:
dart
DemoHomeWidgetDeepLink.matches(uri)
不要直接让业务路由处理所有外部 URL。
这样可以避免:
-
冷启动进入 404
-
未登录状态跳转异常
-
小组件点击和普通 deep link 混在一起
桌面小组件的推荐标准
结合这次项目实践,我比较推荐下面这套标准:
-
Flutter 侧只封装数据写入、刷新和点击处理,不把平台细节散到页面里
-
Android 和 iOS 分别维护自己的小组件 UI,不强行追求完全一致
-
数据 key 统一集中管理,避免平台侧写错字符串
-
Android
RemoteViews尽量使用简单布局和系统支持控件 -
Android 每次改布局都同步检查 Provider 里的
R.id -
iOS 必须配置 App Group,并确认 App 和 Widget Extension entitlements 一致
-
iOS 17+ 适配
containerBackground -
点击小组件统一使用自定义 scheme,再由 Flutter 侧解析
-
小组件尺寸先少后多,只支持业务真正需要的 family
-
真机测试要覆盖冷启动、后台唤起、已运行点击、重新添加小组件
当前项目中的实现总结
这个 Demo 里的桌面小组件链路大致是:
-
Flutter 使用
home_widget保存标题、描述、按钮等数据 -
Android 通过
HomeWidgetProvider + RemoteViews读取数据并渲染 -
iOS 通过
WidgetKit + UserDefaults(suiteName:)读取数据并渲染 -
Android 和 iOS 都使用
demo://home-widget/open作为点击 URL -
Flutter 侧统一识别该 URL,然后跳转到 App 首页或开屏页
-
Android 当前使用
2x2小方块布局 -
iOS 当前只支持
.systemSmall
这套方案的核心不是"写一个漂亮的小组件",而是把小组件的数据流、刷新链路和点击链路打通。
只要这三条链路稳定,小组件 UI 后续怎么调整都比较好维护。
结束:
这篇文章就先写到这里。
相比普通 Flutter 页面,桌面小组件更像是一个跨平台、跨进程、受系统调度约束的"小型原生功能"。它看起来只是桌面上一张小卡片,但真正落地时,需要同时处理:
-
Flutter 数据写入
-
Android AppWidget
-
iOS WidgetKit
-
App Group
-
Deep Link
-
系统缓存
-
不同平台的尺寸和刷新策略
所以做桌面小组件时,重点不是把代码堆起来,而是先想清楚:
-
小组件展示哪些数据
-
数据从哪里写入
-
什么时候刷新
-
点击后进入哪里
-
Android 和 iOS 的布局是否需要分别设计
-
系统缓存和刷新失败怎么排查
这些问题理顺之后,home_widget 在 Flutter 项目里还是比较好用的。它把 Flutter 和原生小组件之间最麻烦的桥接层封装掉了,我们只需要把平台 UI 和业务链路设计好。
当然,桌面小组件不是所有页面都适合做。它更适合展示轻量、固定、高频入口类信息,比如:
-
快速记录
-
今日待办
-
最近内容
-
常用快捷入口
-
关键状态提醒
如果内容过重、交互复杂、刷新要求实时,那它就不一定是最合适的方案。
技术最终还是服务业务。桌面小组件的价值,在于让用户不用打开 App,也能快速看到关键信息或直接进入关键路径。