如果你是一个 .NET MAUI 开发者,想给自己的 iOS 应用补上一个主屏幕小部件,你很快会发现一个让人难受的事实:Visual Studio 的新建项目面板里找不到 iOS Widget 模板,dotnet new 同样没有现成的 MAUI Widget 模板。这并不代表这件事做不了,而是说它需要一种更务实的姿态:iOS 小部件是 WidgetKit 的地盘,而 WidgetKit 只能以原生扩展的形式跑在苹果的独立进程里;.NET MAUI 负责的是 App 本体的业务逻辑、界面和数据存储。平时我们说的“用 .NET MAUI 构建 iOS 小部件”,真正拆开来看,目标是:把原生 Widget Extension 和 MAUI 宿主应用装进同一个 App 安装包,并把两边的数据链路填平。这篇文章就是按这条真实可行的路线来拆解,适合已经跑通过一个 MAUI iOS 应用、想在应用列表上加小部件入口的开发者阅读。
1. 先想清楚:“用 MAUI 做 iOS 小部件”到底指什么
1.1 主屏幕小部件的真实面目
苹果在 iOS 14 推出的主屏幕 Widget,不是过去那种可以随便放控件、随便跑逻辑的桌面挂件。它的运行机制非常克制:Widget 渲染在系统进程管理的“时间线”上,展示内容由 WidgetKit 根据你提供的 Timeline 数据生成。换句话说,Widget 不是 App 的某个页面缩小了挤进主屏幕,而是独立的“只读展示单元”。
这种设计的原因很简单:主屏幕是系统最容易卡顿和耗电的地方之一。苹果不希望每一个 Widget 背后都常驻一个完整 App。所以在 iOS 的架构里,Widget 是以 App Extension 形式存在的,名字后缀是 .appex,被单独签名、单独运行,也不能随便访问主 App 的沙盒目录。开发语言目前看是完全绑定 SwiftUI 的,Widget 的 View 必须写 SwiftUI,不能塞一个 UIView,也不能直接放 MAUI 控件。
这一点很多人一开始会踩坑。他们以为可以用 MAUI 的 ContentView 画一个小卡片,然后直接把 UI 渲染进 Widget。现实是,Widget 无法继承主 App 的 UI 渲染栈,你不可能在小部件里启动 MAUI 的 Application,也不可能在扩展进程里加载 XAML。WidgetKit 从设计上就只认 SwiftUI 描述。这意味着“用 MAUI 写 Widget UI”这条路目前并不存在。
1.2 MAUI 自身能承担和不能承担的
先看清边界,后面做工程才不会白费力气。
MAUI 在这类需求里能承担的部分包括:作为宿主 App,负责鉴权、拉数据、让用户设置内容;在用户操作后,把最新状态写入 App Group 共享容器;甚至通过远程推送驱动的 WidgetCenter 重载逻辑间接刷新 Widget。从 iOS 13 开始系统支持 URL Scheme,到了 iOS 14 Widget 也能通过 widgetURL 把点击事件带回 App,所以宿主内的跳转也可以完全交由 MAUI 的 OpenUrl 处理。
MAUI 不能承担的部分则很明确:小部件本身的扩展 Target、Widget 的 SwiftUI 视图、Timeline 的生成策略。目前你只能在 Xcode 里新建 Widget Extension,用 Swift 写 UI 渲染,然后把编译好的 .appex 嵌进 MAUI 生成的 .app 包里。
这里我多说一句:如果你搜到一些号称“纯 MAUI 写 Widget”的 NuGet 包,要警惕它们的真实程度。有的包是在 MAUI 层封装了“日历、提醒事项”之类的系统视图来实现显示,不是真正的主屏幕 Widget;有的包只是把 Swift 生成的代码做成绑定库,并没有绕开 SwiftUI。真正的 iOS Widget,从系统角度看必须是一个原生 WidgetKit Extension。
1.3 当前最值得采用的工程路线
既然 Widget 必须原生,那我们能选择的就是“原生扩展 + MAUI 宿主”的组合。具体分两条落地方案,我分别说。
第一种是“Xcode 负责扩展、MAUI 负责宿主”的工程分离。你维护两个仓库:WidgetExtension 用 Xcode 单独管理,主 App 用 Visual Studio 跑 MAUI。最终产物把 .appex 拷到 MAUI App 的 PlugIns 目录下,手动签好名再发布。优点是两个工程互不干扰,调试 Widget UI 很方便;缺点是多了一个工程,CI 流程要处理两套构建。
第二种是“扩展嵌入 MAUI 单工程”。你把 Widget Extension 的 Xcode 工程放在 MAUI 解决方案里,通过自定义 MSBuild Target 在 MAUI 构建结束后,把 .appex 复制进 App Bundle。这样开发者可以只点一下 Visual Studio 的 Run,出的是带 Widget 的完整 App。缺点是需要折腾签名顺序,否则真机会出现“Extension 无效”的安装报错。
以我接触过的项目来说,团队规模小、Widget 复杂度不高的时候,第二条最省心,只要把签名脚本维护稳定,后续迭代非常快。下面的实操我会以这条路线为主展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前必须理解的三个底层机制:Timeline、App Group、交互入口
2.1 Timeline Provider 是怎么决定 Widget 内容的
Widget 不是“页面随时刷新”的模式,它使用时间线模型。你在 Widget Extension 里要实现一个 TimelineProvider,只要数据更新了、时间到了,系统就来向你的 Provider 询问未来某段时间内要显示什么。Provider 会返回一个 Timeline,里面塞一个或多个 TimelineEntry。
每个 TimelineEntry 都包含一个时间点以及一份数据。系统到了某个 Entry 的时间点时,就会渲染对应视图。比如你在早上 8 点创建一个Entry,附上“距离会议开始还有 2 小时”的数据,等到 10 点这个 Entry 过期后,系统就会向 Provider 重新请求下一条 Entry,或者使用你在 Timeline 里预留的下一条。
为了节约资源,WidgetKit 重载 Timeline 的频率是有限制的。如果你每次都返回一条“当前时间 + 当前数据”的 Entry,系统会认为这个 Widget 不需要频繁更新,可能几小时才刷新一次。反过来,如果你想做“每 5 分钟显示一次倒计时”,必须把未来半小时的 6 个 Entry 一次性准备好。
这点很关键:MAUI 宿主 App 不能在后台无限刷新 Widget,它只能通过 WidgetCenter.shared.reloadAllTimelines() 主动告诉系统“我有新数据了”。具体什么时候重新拉 Timeline,由系统决定。所以从架构上说,Widget 必须能在“没有宿主 App 参与”的情况下,独立地根据时间推断出应该显示的内容。
2.2 宿主 App 与 Widget 之间靠什么交换数据
Widget 运行在独立的扩展进程里,它和宿主 App 的沙盒不互通。为了让两边共享设置项和业务数据
