1. 先说清楚:Flutter 项目里那个 iOS 小组件,到底是谁在干活
做 Flutter 项目做到一定阶段,产品那边一定会提一句"我们要不要加个桌面小组件"。第一次听到这个需求的时候,我的第一反应是去 pub 上搜插件,搜了一圈发现:Flutter 侧根本没有官方的小组件能力。原因很简单,iOS 小组件(WidgetKit Extension)本质上是一个独立的原生 App Extension,它有自己的进程、自己的生命周期、自己的渲染环境,跟 Flutter 那套 Skia 渲染管线完全是两条路。
所以这件事的正确认知是:你在 Flutter 项目里加 iOS 小组件,实际做的是"在一个 Flutter 工程外壳里,塞进一个用 SwiftUI 写的原生扩展",Flutter 负责主 App 的界面和数据生产,SwiftUI Extension 负责在桌面上把数据渲染出来,两者之间靠共享容器传数据。这个定位想明白了,后面的坑基本都能预判。这篇文章我会完整复盘一遍从零加小组件的过程:方案怎么选、Target 怎么配、数据怎么通、点击怎么跳回来、上架前怎么排雷,适合已经能跑通flutter run、但对 iOS 原生工程还有点发怵的同学。
1.1 三条可选路线,为什么最后都指向 Widget Extension
在动手前我把能想到的方案列了一遍,大概三条:
| 路线 | 做法 | 致命问题 |
|---|---|---|
| 纯插件方案 | 用社区的小组件插件,Dart 侧配置数据即可 | 插件大多只覆盖"数据共享 + 简单刷新",复杂布局照样要写 SwiftUI,等于多学一套 API |
| WebView / 图片截图 | 主 App 渲染好页面,截图存进去当静态图 | 只能显示不能交互,分辨率适配糟糕,且无法响应时间变化 |
| 原生 Widget Extension | 在 Runner 工程里新建 WidgetKit 扩展,SwiftUI 写布局 | 需要维护一份原生代码,且要处理签名和工程配置 |
第三条是唯一正解。前两条不是不能用,而是用起来以后你会发现:真正的活儿一点没少,反而多了一层不好调试的中间层。我当时的判断标准很朴素——如果这个功能最终必须有交互、必须跟随时间或数据变化,那就老老实实写 WidgetKit。
还有一个容易被忽略的对比维度:Android 那边同样有桌面小组件,Flutter 生态里有个插件可以做到 Dart 侧注册和更新,跨端体验相对统一。但 iOS 这边 WidgetKit 的设计哲学就是要你"声明时间线",系统来决定什么时候刷新,这种模式跟 Android 的RemoteViews更新逻辑差别很大。所以如果项目要双端,别指望一套抽象能吃下两端,做好"iOS 单独维护一份 SwiftUI 代码"的心理准备。
1.2 数据怎么从 Dart 走到 SwiftUI:共享容器的选型
这是整件事的技术核心。主进程和扩展进程是两个独立的沙箱进程,普通文件互相看不见,标准的UserDefaults也是各存各的。唯一被系统承认的通道是App Group(应用组),它是一个由开发者账号下发的共享容器标识,主 App 和所有扩展在能力(Capabilities)里勾选同一个 group 之后,就可以通过这个 group 读写同一份UserDefaults或者同一个目录。
我调研过几个具体手段:
shared_preferences:Dart 侧最常用,但它的 iOS 实现走的是UserDefaults.standard,没有暴露 App Group 参数,写进去的数据扩展读不到。搜到的一些"技巧"是改插件源码,那等于把维护成本转移到自己身上,我放弃了。- 文件 +
path_provider:同样拿不到 App Group 容器路径,getApplicationSupportDirectory返回的是主 App 沙箱内的目录。 - 自己写 MethodChannel:在
AppDelegate里注册一个通道,Dart 调用,Swift 侧写进UserDefaults(suiteName: groupId)。
第三条路径代码量其实很小,三十行以内,而且完全可控。我最终选它,还有一个隐性理由:写数据的同时可以顺手触发WidgetCenter.shared.reloadTimelines,把"存"和"通知刷新"绑在一个原子操作里,避免出现数据更新了但桌面还是旧的情况。
提示:App Group 的标识符不是随便起的,必须是开发者账号里注册过的、且和 App ID 关联的 group。命名习惯上写成
group.反向域名.项目名,本文示例统一用group.com.example.demo。
1.3 工程结构落位:别把原生代码塞进 lib 目录
一开始我把生成的 Swift 文件丢在了ios/Runner目录下,结果整个工程结构变得很脏,而且 Xcode 索引也乱了。后来调整成下面这种结构,可读性明显好很多:
ios/ ├── Runner.xcworkspace ├── Podfile ├── Runner/ │ └── AppDelegate.swift └── DemoWidget/ # 新建的 Extension target 目录 ├── DemoWidget.swift # Widget 入口 + Configuration ├── DemoProvider.swift # TimelineProvider ├── DemoWidgetView.swift # SwiftUI 布局 ├── SharedSnapshot.swift # 数据模型(Codable) ├── Info.plist └── Assets.xcassets关键点是模型文件单独抽出来。因为数据模型在主 App 和扩展两边都要用,如果以后想在主 App 里读一下当前 Widget 状态做调试,直接复用这个文件就行。有人会再建一个 Shared Framework target 来放模型,我不建议这么干——为了一两个结构体多一个 target,签名和嵌入配置的复杂度翻倍,不划算。
2. Target 配置和签名:这步做不对,后面全是玄学
我见过太多人卡在这一步:代码写完了,flutter run起不来,或者真机上 Widget 一直显示默认占位内容。八成是签名或 App Group 没配对。这一节把配置过程拆细,尽量让第一次做的人少走弯路。
2.1 版本矩阵与目录基线
先说我当时的环境,因为 Xcode 大版本之间差异很大,尤其是 Widget 的背景 API 和可交互能力:
- Flutter 3.x 稳定版,iOS 部署目标 13.0 起步,Widget 扩展单独设 14.0
- Xcode 15.x,Swift 5.9
- 真机与模拟器各测一遍,模拟器上
UserDefaults(suiteName:)有时行为不一致,一定要以真机为准
目录基线没什么特别的,就是标准的 Flutter 工程,ios目录下用 Xcode 打开Runner.xcworkspace(不是.xcodeproj),这点别搞错,否则 CocoaPods 的依赖看不到。
Extension 的 Deployment Target 我设成和主 App 一致或更低。有个细节:如果 Widget 的最低版本设得比主 App 高,老系统设备上主 App 装得上但扩展装不上,上传时会被打回;反过来设低了,SwiftUI 的新 API 就要老老实实加@available判断。我的选择是"主 App 13.0,Widget 14.0",WidgetKit 本身就是 14.0 才有的,设更低没有意义。
2.2 在 Runner 工程里新建 Widget Extension
流程本身不复杂,但有几个选项必须选对:
- Xcode 菜单
File > New > Target - 选Widget Extension,不要选 "Extension" 里的其他类型
- Product Name 填
DemoWidget,注意这个名字最后会变成 target 名和默认的 bundle id 后缀 - 不要勾选 "Include Configuration App Intent",除非你确实要做可配置参数的小组件。勾了会多出一堆 Intent 模板代码,新手很容易被绕晕
- 创建完成后,Xcode 会弹窗问 "Activate DemoWidget scheme?",选Activate或 Cancel 都行,但如果你选了 Activate,后续
flutter run记得手动切回 Runner scheme
建完之后检查 Target 的General页:
Bundle Identifier必须是主 App 的前缀扩展。比如主 App 是com.example.demo,Widget 就得是com.example.demo.DemoWidget。这条是硬性规则,上架时校验很严Version和Build直接改成$(MARKETING_VERSION)和$(CURRENT_PROJECT_VERSION),跟主 App 复用同一套变量。手动填数字的后果是某次改版本忘了同步,上传时直接报版本不一致Frameworks, Libraries, and Embedded Content里不要加 Flutter.framework,也不要加 App.framework
最后一条是我最想强调的:Widget Extension 里绝对不能import Flutter。一旦引入,整个 Flutter Engine 会被打进扩展包里,Widget 体积从几百 KB 涨到十几 MB,而且扩展进程的内存上限只有几十 MB,加载引擎大概率被系统杀掉,表现就是"白屏 + 闪退日志"。这个坑我踩过一次,排查了两个小时才定位到是某个人在 Podfile 里顺手给 Widget target 也加了flutter_install_all_ios_pods。
Podfile 这边,如果 Widget 完全不用第三方 pod,就什么都别写。改完 target 后执行一次pod install,让工程重新生成配置即可。如果确实需要(比如共享一个轻量的网络库),写法是单独开一个 target block,且不继承 Flutter 的搜索路径:
target 'DemoWidget' do # 只放纯 Swift 的轻量依赖,不要放 Flutter 相关 # pod 'SomeLightweightLib' end2.3 App Group 与签名:最容易翻车的一步
这是全流程里最容易出问题的地方,我把它拆成三个动作,缺一不可:
动作一:在开发者后台的 Identifiers 里,为两个 App ID(主 App 和 Widget)都勾选 App Groups 能力,并关联同一个 group 标识。
动作二:Xcode 里选中 Runner target →Signing & Capabilities→+ Capability→ App Groups → 添加上面那个 group,勾选它。然后选中 DemoWidget target 重复一遍完全相同的操作。两边必须都是勾选状态,只配一边是最常见的失误。
动作三:配置变了之后,Xcode 会自动重新生成描述文件。如果是在团队协作环境里(手动管理签名),必须去后台重新下载 mobileprovision,替换掉旧文件。
验证 App Group 是否真的生效,我写了一段临时诊断代码,放在主 App 启动时跑一次:
let groupId = "group.com.example.demo" if let defaults = UserDefaults(suiteName: groupId) { defaults.set("probe", forKey: "group_probe") print("[AppGroup] 写入成功, 当前值: \(defaults.string(forKey: "group_probe") ?? "nil")") } else { print("[AppGroup] 容器不可用, 检查 Capability 与描述文件") }如果UserDefaults(suiteName:)返回nil,不用怀疑代码,百分之百是签名或能力没配好。这种情况在模拟器上有时会"侥幸成功",因为模拟器的容器校验比真机松,所以真机验证是唯一可信的验证。
注意:改 App Group 配置后,务必执行一次完整的卸载重装(不是覆盖安装)。旧版本 App 的描述文件和容器权限缓存会让你怀疑人生。
3. 数据打通与时间线:Widget 不刷新,八成是这里的问题
配置搞定之后进入正题。这一段我按"写入—读取—渲染—刷新"四条线展开,每条线都附上我实际在用的代码。
3.1 Dart 侧:把共享容器封装成一个极简通道
Dart 侧我只做一件事,把要展示的数据打包成 Map 丢过去。封装成一个小类,全项目就一个入口:
import 'package:flutter/services.dart'; class WidgetBridge { static const _channel = MethodChannel('demo/widget_bridge'); /// 把小组件需要展示的数据推到 iOS 共享容器 static Future<bool> pushSnapshot({ required String title, required String subtitle, }) async { try { await _channel.invokeMethod('pushSnapshot', { 'title': title, 'subtitle': subtitle, 'updatedAt': DateTime.now().millisecondsSinceEpoch / 1000, }); return true; } on PlatformException catch (e) { // 常见于 App Group 未配置,不要让主流程崩掉 print('[WidgetBridge] 同步失败: ${e.code} / ${e.message}'); return false; } } }几个设计取舍值得说一下:
- 只传展示所需的字段,不要整个业务对象序列化过去。Widget 的解析逻辑在 Swift 侧,字段越少越不容易因为后端改结构而崩。
- 时间戳用秒级 double,因为
UserDefaults原生支持Double,走Date().timeIntervalSince1970对齐,两边不用做转换。 - 异常必须吞掉。小组件是锦上添花的功能,不能因为同步失败把主流程带崩。我见过有团队在这里直接
throw,结果 App Group 配置出错的时候整个下单流程报错,非常离谱。
调用时机我放在三个地方:App 启动后首次数据加载完成、用户手动下拉刷新后、以及关键的写操作成功之后。不要在每一帧或者滚动回调里调,MethodChannel 虽然不慢,但没必要。
3.2 Swift 侧写入:存数据的同时把刷新一起做掉
AppDelegate里注册通道。这段代码要注意window?.rootViewController的取值时机,Flutter 3.x 之后某些场景下拿不到,稳妥的写法是从FlutterViewController的插件注册器里取:
import Flutter import UIKit import WidgetKit @main @objc class AppDelegate: FlutterAppDelegate { private let groupId = "group.com.example.demo" override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) if let controller = window?.rootViewController as? FlutterViewController { let channel = FlutterMethodChannel(name: "demo/widget_bridge", binaryMessenger: controller.binaryMessenger) channel.setMethodCallHandler { [weak self] call, result in guard call.method == "pushSnapshot" else { result(FlutterMethodNotImplemented) return } guard let groupId = self?.groupId, let defaults = UserDefaults(suiteName: groupId) else { result(FlutterError(code: "NO_APP_GROUP", message: "共享容器不可用", details: nil)) return } let args = call.arguments as? [String: Any] ?? [:] defaults.set(args, forKey: "widget_snapshot") defaults.set(Date().timeIntervalSince1970, forKey: "widget_updated_at") WidgetCenter.shared.reloadTimelines(ofKind: "DemoWidget") result(true) } } return super.application(application, didFinishLaunchingWithOptions: launchOptions) } }关键点在于reloadTimelines(ofKind:)。这里传的kind必须和 Widget 配置里StaticConfiguration(kind: ...)的字符串一模一样,写错一个字都不会刷。我一开始写成了类名,结果调试了半天,因为系统不会报错,就是静默不刷。
3.3 读取与时间线:Provider 里那个"下次刷新时间"才是灵魂
WidgetKit 的模型是"给系统一份未来时间线的计划表,系统按自己的节奏来取"。所以 Provider 里最关键的不是怎么读数据,而是你告诉系统什么时候该来拿新的。
import WidgetKit import SwiftUI struct DemoEntry: TimelineEntry { let date: Date let title: String let subtitle: String let updatedAt: Date? } struct DemoProvider: TimelineProvider { private let groupId = "group.com.example.demo" func placeholder(in context: Context) -> DemoEntry { DemoEntry(date: Date(), title: "暂无数据", subtitle: "打开应用同步一下", updatedAt: nil) } func getSnapshot(in context: Context, completion: @escaping (DemoEntry) -> Void) { completion(loadEntry()) } func getTimeline(in context: Context, completion: @escaping (Timeline<DemoEntry>) -> Void) { let entry = loadEntry() // 半小时后再来问一次;期间若主 App 主动 reload,会立刻刷新 let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date().addingTimeInterval(1800) completion(Timeline(entries: [entry], policy: .after(next))) } private func loadEntry() -> DemoEntry { guard let defaults = UserDefaults(suiteName: groupId), let raw = defaults.dictionary(forKey: "widget_snapshot") else { return DemoEntry(date: Date(), title: "暂无数据", subtitle: "打开应用同步一下", updatedAt: nil) } let ts = defaults.double(forKey: "widget_updated_at") return DemoEntry( date: Date(), title: raw["title"] as? String ?? "暂无数据", subtitle: raw["subtitle"] as? String ?? "", updatedAt: ts > 0 ? Date(timeIntervalSince1970: ts) : nil ) } }关于policy的选择,我总结了一张对照表,实际项目里按场景挑:
| policy | 行为 | 适用场景 |
|---|---|---|
.atEnd | 时间线用完后再来要 | 一次性展示、不需要定时更新的内容 |
.after(Date) | 到指定时间点再来要 | 有明确更新节奏(如半小时一次) |
.never | 永不主动来要,只能靠 App 主动 reload | 完全由用户操作驱动的数据 |
我选.after(30分钟)的原因是:这类小组件的数据本身变化不频繁,靠主 App 主动 reload 已经能覆盖大部分更新,定时兜底只是为了处理"用户很久没打开 App 但数据在服务端变了"的情况。
这里必须泼一盆冷水:iOS 对 Widget 刷新是有预算的。官方文档只说"由系统根据使用频率等因素决定",没有给出确切次数。社区里长期统计的经验值大致是每天几十次的量级,用得越勤的 Widget 配额越多。所以千万别指望靠高频.after做秒级更新,那种需求应该做 Live Activity 或者干脆引导用户打开 App。
提示:如果你发现 Widget 更新明显滞后,先别怀疑代码,去设置里把这个 App 的"后台 App 刷新"打开试试。另外,低电量模式下刷新会被大幅抑制,这是正常现象。
3.4 三种尺寸的布局适配,以及 iOS 17 那个必须处理的背景 API
Widget 的尺寸由系统决定,你能做的只是声明支持哪几种,然后在视图里按widgetFamily分支处理。我在这个项目里支持了小号、中号、锁屏矩形三种,尺寸大致是:
| family | 大致尺寸(pt) | 内容策略 |
|---|---|---|
| systemSmall | 约 155 × 155 | 只放一个核心数值 + 一行标签,超过 8 个字符就截断 |
| systemMedium | 约 329 × 155 | 左侧主数值,右侧两行辅助信息 |
| accessoryRectangular | 锁屏宽度,高度较小 | 单行文本 + 图标,不要放可交互元素 |
布局代码大概是这个骨架,关键是用@Environment(\.widgetFamily)做分支,而不是试图写一套响应式布局把三种尺寸都撑起来——实践下来后者永远会在某个尺寸上翻车:
struct DemoWidgetView: View { var entry: DemoEntry @Environment(\.widgetFamily) private var family var body: some View { Group { switch family { case .systemSmall: compactLayout case .accessoryRectangular: lockScreenLayout default: mediumLayout } } .modifier(WidgetBackgroundModifier()) } } struct WidgetBackgroundModifier: ViewModifier { func body(content: Content) -> some View { if #available(iOS 17.0, *) { // iOS 17 起必须走 containerBackground,否则会报 "Please adopt containerBackground API" content.containerBackground(for: .widget) { Color(.secondarySystemBackground) } } else { content.background(Color(.secondarySystemBackground)) } } }那个containerBackground是 iOS 17 的破坏性变更,我当时在 Xcode 15 上跑模拟器,控制台直接刷了一屏紫色警告,Widget 背景变成一片透明。加上if #available分支之后世界清净了。
还有个布局上容易忽略的点:Widget 的圆角是系统裁的,不要自己加cornerRadius,加了会出现双重圆角。左右两侧还要预留系统边距,padding给.horizontal, 16左右比较稳。
4. 点击跳转:让用户从桌面回到 Flutter 的指定页面
纯展示的小组件价值有限,真正好用的是"点一下直接进到对应详情页"。这条链路是:Widget 里挂一个 URL → 系统唤起主 App → Flutter 侧拦截 URL → 路由到目标页。
4.1 URL Scheme 注册与 widgetURL
第一步,在主 App 的Info.plist里注册自定义 scheme:
<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLName</key> <string>com.example.demo.deeplink</string> <key>CFBundleURLSchemes</key> <array> <string>demowidget</string> </array> </dict> </array>scheme 名字取短一点、有辨识度一点,别用app、demo这种大路货,容易被别的应用抢注导致跳转错乱。
第二步,在 Widget 的视图上挂widgetURL。注意:一个 Widget 在同一个时刻只能有一个生效的 URL,如果你贩卖了多个可点区域,要么用 iOS 17 的Link做分区点击,要么就统一跳一个页面。
StaticConfiguration(kind: "DemoWidget", provider: DemoProvider()) { entry in DemoWidgetView(entry: entry) .widgetURL(URL(string: "demowidget://detail?source=widget")) } .configurationDisplayName("数据速览") .description("展示最近一次同步的概要信息") .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular])小号 Widget 整块区域都会被点中,用widgetURL就够了。中号如果要拆成左右两个可点区域,就得在视图里用Link(destination:)包住子视图;锁屏 Widget 不支持Link,只能用widgetURL。
4.2 Flutter 侧接收:冷启动和热启动必须分开处理
这是第二个高频坑。很多人在 Dart 侧只监听了 link stream,测试时从后台切回来能跳,但从完全退出状态点小组件进来就没反应——因为冷启动时 URL 在你注册监听之前就已经被系统分发完了,stream 里什么都没收到。
我的处理方式是两个入口都接:
import 'package:app_links/app_links.dart'; class DeepLinkHandler { final _appLinks = AppLinks(); Future<void> init() async { // 1. 冷启动:主动取一次初始链接 final initial = await _appLinks.getInitialLink(); if (initial != null) { _route(initial); } // 2. 热启动:监听后续链接 _appLinks.uriLinkStream.listen((uri) { _route(uri); }, onError: (e) { print('[DeepLink] 解析失败: $e'); }); } void _route(Uri uri) { if (uri.scheme != 'demowidget') return; if (uri.host == 'detail') { final source = uri.queryParameters['source'] ?? 'unknown'; // 交给全局路由或 navigatorKey,注意别在 init 阶段直接 push router.go('/detail?source=$source'); } } }三个实践细节:
getInitialLink()要在runApp之后、首帧渲染完成再处理,否则 Navigator 还没准备好,跳转会静默失败。用WidgetsBinding.instance.addPostFrameCallback包一层。- 路由跳转时如果目标页需要登录态,先做一次鉴权再跳,避免用户点小组件进来看到一片空白。
- URL 里带参数时做好默认值兜底,
queryParameters取不到要给unknown,不要让空值进到业务逻辑。
注意:
getInitialLink在不同插件版本里行为有差异,有的返回Uri?,有的需要getInitialLinkString。升级插件后务必回归测试这个冷启动路径,它属于"平时不出现、一出现就是线上事故"的类型。
5. 联调排错:那些 Xcode 不会明确告诉你的问题
到这一步功能基本通了,接下来是更容易耗时间的部分——排查各种"看起来没问题但就是不生效"。
5.1 调试姿势与热重载的边界
先说清楚调试方式,能省掉很多无效尝试:
- 改 Swift 代码,必须重新
flutter run或从 Xcode 重新 Run。热重载只作用于 Dart 层,对 Swift 完全无效,这是新手最常见的困惑来源。 - 想单独调 Widget,在 Xcode 顶部 scheme 选
DemoWidget,Run 之后系统会问"要添加到桌面吗",选是,然后桌面就有预览了。这种方式改代码后需要重新 Run 一次。 - 更快的办法是用 SwiftUI Preview。给视图加一个
#Preview,传入几条假数据,改布局时秒级预览,不用等编译整个 App。 flutter run会连带编译 Widget Extension。因为 Extension 是被嵌入到 Runner.app 里的,所以 Widget 的 Swift 编译错误会直接让flutter run失败,而报错信息在终端里往往被截断,得去 Xcode 的 Report Navigator 里看完整日志。这一点很重要,我团队里有人一直以为"我的 Widget 代码不影响 Flutter 运行"。
还有一个进阶技巧:调试 Provider 的刷新逻辑时,在getTimeline里打日志,然后把 Widget 加到桌面,手动改一下系统时间或者等上半小时,观察 Xcode 控制台。如果日志一直不出现,说明你的 Widget 根本没被系统调度——先检查kind字符串是否和reloadTimelines里的一致。
5.2 常见问题速查表
下面这张表是我把两个项目踩过的坑汇总出来的,基本覆盖了 90% 的异常:
| 现象 | 大概率原因 | 排查动作 |
|---|---|---|
| Widget 一直显示占位内容 | UserDefaults(suiteName:)返回 nil | 两边 target 的 App Group 是否都勾选 |
| 数据有更新但桌面不变 | 没调reloadTimelines,或kind写错 | 检查 kind 字符串是否与 Configuration 完全一致 |
flutter run报 Swift 编译错误 | Widget Extension 参与编译失败 | 打开 Xcode 看完整日志,确认是否误 import 了 Flutter |
| 真机上 Widget 装不上 | Extension 版本号与主 App 不一致 | 检查 Version / Build 是否复用变量 |
| 上传时报扩展 Bundle ID 错误 | 没以主 App ID 为前缀 | 改成com.xxx.app.DemoWidget |
| 点小组件没反应 | URL scheme 未注册或拼写不一致 | 对照 Info.plist 与 widgetURL 逐字符核对 |
| 冷启动进来不跳转 | 只监听了 stream | 补getInitialLink处理 |
| iOS 17 上背景透明/发紫 | 没适配containerBackground | 加if #available(iOS 17.0, *)分支 |
| Widget 体积异常大 | 引入了 Flutter.framework | 检查 Podfile 与 Link Binary 列表 |
| 偶尔白屏后自动恢复 | 扩展内存超限被杀 | 精简视图层级,避免在 Widget 里做网络请求 |
最后一条特别说明一下:Widget 里不要直接发网络请求。一是扩展的生命周期很短,异步请求很容易在你拿到结果之前进程就被回收了;二是每次刷新都打网络会让刷新变得不稳定。正确做法是主 App 负责拉数据,写完共享容器再通知刷新,Widget 只做"读本地、渲染"这一件事。这个边界划清楚之后,稳定性提升非常明显。
5.3 包体积与上架前的检查清单
体积这块,加了 Widget 之后的增量应该在一两百 KB 到几百 KB 之间。如果发现.ipa突然变大了好几 MB,基本可以确定是把 Flutter 引擎或者资源目录整包带进去了。用 Xcode 的Product > Archive打开产物,右键Show Package Contents,进PlugIns目录看扩展包大小,一眼就能定位。
上架前我固定会走一遍这几条:
- 用 Release 配置 Archive 一次,确认没有签名警告
- 桌面添加三种尺寸各一次,检查布局截断情况,尤其是长文案
- 断网状态下打开 App,确认 Widget 能显示上一次缓存的数据而不是空白
- 从完全退出状态点小组件,确认能正确跳到目标页
- 检查扩展的
Info.plist里NSExtensionPointIdentifier是com.apple.widgetkit-extension - 检查扩展的
configurationDisplayName和description,这两个字符串会直接展示在用户添加小组件的选择界面里,写成英文或者写成默认的 "DemoWidget" 很不专业
6. 维护一段时间之后的一些体会
这东西上线之后不是就完事了,随着 Flutter 升级、Xcode 升级、iOS 大版本更新,它会持续找你要维护成本。分享几条我实实在在的体感。
6.1 每次系统大版本更新都要回归一遍
iOS 17 那次改动就是典型:containerBackground强制要求,不加就有警告,加了之后老代码的background又和新的冲突;再往前,锁屏 Widget、可交互 Widget(AppIntent)都是新版本才有的能力,用上了就得处理降级。所以我把小组件列进了"每次 iOS 大版本更新必须回归"的清单里,跟推送、权限、后台任务放在一起。
另一个隐藏风险是Flutter 升级导致的引用变化。AppDelegate的模板在 Flutter 3.x 期间改过好几次,如果你在里面加了不少代码,升级时合并冲突会很恶心。我的做法是把所有小组件相关的逻辑抽到一个独立的 Swift 文件里,AppDelegate只留一行调用,这样升级模板冲突时直接保留模板代码、重新接上那行调用就行。
6.2 团队协作里必须约定两件事
第一件:谁改 Widget,谁负责跑真机验证。这件事没法靠 CI 完全兜住,模拟器和真机的行为差异(尤其是 App Group)太大。我们现在的约定是 PR 描述里必须贴一张真机桌面的截图,否则不予合并。
第二件:共享容器的 key 当成对外接口来管理。widget_snapshot这种字符串一旦上线,改名字就意味着新老版本不兼容——用户如果没更新 App 但 Widget 更新了,读不到数据就是一片空白。我们现在是把所有 key 集中放在一个 Swift 的enum WidgetKeys里,Dart 侧也维护一份常量,两边加注释说明字段含义和默认值,改字段必须同步改两边并保留旧 key 的兼容读取。
说句实在话,小组件这个功能的投入产出比,取决于你的产品是不是"高频看一眼"的类型。如果是待办、日程、数据看板这类,用户确实需要抬眼就看到,那这套原生代码值得维护;如果只是老板觉得"别家 App 都有",那我建议先想清楚要展示什么再动手——因为一旦上线,这块 SwiftUI 代码就成了一个常年需要跟着系统版本走的资产,加进来容易,拆出去难。