Flutter 桌面小组件开发

前言:

最近在项目里接入了一版桌面小组件,一开始以为只是 Flutter 里加一个组件,然后把它显示到桌面上就可以了,真正做下来才发现,桌面小组件并不是 Flutter 页面的一部分。
Android 端走的是系统 AppWidget,iOS 端走的是 WidgetKit。Flutter 只负责把数据写出去、通知系统刷新、接收点击回调,真正展示到桌面上的 UI 还是要分别在 Android 和 iOS 原生侧实现。
所以这篇文章就结合当前这个 Demo,聊一聊在 Flutter 项目里如何使用 home_widget 开发桌面小组件,以及 Android、iOS 两端分别需要做哪些工作。文章不单独讲 API,而是偏项目落地,重点说清楚实现链路、平台差异和踩坑点。

正文:

这个 Demo 中的桌面小组件实现,主要从下面几个维度来理解:

  1. Flutter 桌面小组件的整体思路

  2. home_widget 插件做了什么

  3. Flutter 侧如何写入数据和刷新小组件

  4. Android 端需要做哪些工作

  5. iOS 端需要做哪些工作

  6. 点击小组件如何跳回 App 首页

  7. 实现过程中遇到的问题和坑

  8. 桌面小组件开发的推荐标准

桌面小组件的基本思想

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 侧提供 HomeWidgetProviderHomeWidgetLaunchIntent 等辅助能力

  • iOS 侧配合 App Group,让 App 和 Widget Extension 共享数据

所以可以把 home_widget 理解成一个"数据桥 + 刷新桥 + 点击桥"。

它不负责:

  • 自动生成 Android 小组件布局

  • 自动生成 iOS WidgetKit 页面

  • 自动处理所有系统缓存

  • 自动解决小组件尺寸适配

  • 自动把 Flutter Widget 转成原生 Widget

这也是很多同学刚接入时容易误解的地方。

Flutter 侧完整流程

Flutter 侧主要做三件事:

  1. 初始化小组件服务

  2. 保存小组件数据

  3. 通知系统刷新小组件

  4. 处理点击小组件后的跳转

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 端主要包含四部分:

  1. 新建 AppWidgetProvider

  2. 编写 RemoteViews 布局

  3. 编写 appwidget-provider 配置

  4. 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 页面。

核心结构通常是:

  1. TimelineEntry

  2. TimelineProvider

  3. Widget View

  4. Widget 配置

  5. 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 代码后,如果真机上还是旧样式,可能不是代码没生效,而是系统还在用旧缓存。

建议排查顺序:

  1. 删除桌面旧小组件

  2. 重新添加小组件

  3. 卸载 App 后重新安装

  4. 必要时重启手机

  • 坑9:只支持 small 时不要声明其它尺寸

如果业务上只需要 small,就只写:

swift 复制代码
.supportedFamilies([.systemSmall])

不要为了让系统展示多个卡片而声明 medium、large。声明了就意味着系统会允许用户添加这些尺寸,后续还要维护对应布局。

  • 坑10:点击 URL 要和路由系统隔离

小组件点击 URL 最好先走一层解析器,比如:

dart 复制代码
DemoHomeWidgetDeepLink.matches(uri)

不要直接让业务路由处理所有外部 URL。

这样可以避免:

  • 冷启动进入 404

  • 未登录状态跳转异常

  • 小组件点击和普通 deep link 混在一起

桌面小组件的推荐标准

结合这次项目实践,我比较推荐下面这套标准:

  1. Flutter 侧只封装数据写入、刷新和点击处理,不把平台细节散到页面里

  2. Android 和 iOS 分别维护自己的小组件 UI,不强行追求完全一致

  3. 数据 key 统一集中管理,避免平台侧写错字符串

  4. Android RemoteViews 尽量使用简单布局和系统支持控件

  5. Android 每次改布局都同步检查 Provider 里的 R.id

  6. iOS 必须配置 App Group,并确认 App 和 Widget Extension entitlements 一致

  7. iOS 17+ 适配 containerBackground

  8. 点击小组件统一使用自定义 scheme,再由 Flutter 侧解析

  9. 小组件尺寸先少后多,只支持业务真正需要的 family

  10. 真机测试要覆盖冷启动、后台唤起、已运行点击、重新添加小组件

当前项目中的实现总结

这个 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,也能快速看到关键信息或直接进入关键路径。

相关推荐
还有多久拿退休金10 小时前
我在自家页面嵌了个 iframe,结果对方说"你不配"——跨域和 CSP 的那些坑
前端·架构
Awu122710 小时前
🍎Google Stitch :用自然语言做 UI 设计,把设计师的活也抢了
前端·aigc·ai编程
竹林81810 小时前
从“连接不上”到“交易成功”:我用 @solana/web3.js 在 React 中搞定 Solana 钱包交互的全过程
前端
YouCanYouUp.11 小时前
掌控感心理学解析:人类最底层的心理需求
前端
wyc是xxs11 小时前
浏览器解析HTML头部的底层逻辑
前端·html
义嘉泰11 小时前
一颗 NAND Flash 的自我修养
前端·人工智能·芯片
liangdabiao11 小时前
【开源】利用Claude Agent SDK能力通过Skill自主构建完整的web
前端·开源
张元清11 小时前
驯服 React 里的 DOM 事件:useEventListener、useEventEmitter、useKeyModifier、useTextSelect
前端·javascript·面试
lichenyang45311 小时前
鸿蒙项目首页启动链路与 ArkUI 架构学习总结
前端