作者|DerekYang,携程高级研发经理,专注于iOS开发&跨端技术研究,热衷于新技术探索。一、前言2020年9月,苹果发布了iOS14.0,其功能相比之前有了很大的提升。用户可以更加个性化地定义自己的桌面是非常重要的,而Widget就是这个功能的主角。最近接到一个产品需求,需要实现几个机票业务相关的widget。本文总结了这个需求开发上线过程中的踩坑和填坑经验。Widget,俗称小组件,是Apple推出的众多AppExtensions之一。因此,在介绍Widget之前,需要了解AppExtension及其工作原理。2、AppExtension简介从iOS8.0开始支持AppExtension的开发,以满足丰富App的需求。2.1什么是应用扩展?AppExtension,顾名思义,就是应用程序的扩展。所以它不是一个应用程序,而是实现一个特定的、范围明确的自定义任务。该任务由开发者定义,遵循系统规范的扩展策略,在用户与其他应用程序或系统交互时提供给用户。AppExtension编译后为二进制文件,后缀为.appex,不能单独分发安装,必须附加到App中。一个App可以挂载多种类型的AppExtensions。到目前为止,苹果已经陆续推出了33款AppExtensions,常见的有PhotoEditing、Share、CustomKeyboard和Widget。如下图所示:2.2AppExtension的工作原理AppExtension的生命周期不同于普通App。它需要一个包含Extension的App(ContainingApp),以及一个调用Extension的App(HostApp)。当用户通过HostApp唤醒Extension时,系统实例化Extension,Extension从Extension的生命周期开始执行自己的任务。之后,当任务执行结束或者用户通过Hostapp结束任务,或者系统因为某种原因终止了它的进程,Extension的生命周期就到此结束。官方简介图:Extension、ContainingApp和HostApp的通信关系,如下官网图所示:从图中可以看出AppExtension和HostApp可以直接通信,但是AppExtension和ContainingApp做不直接沟通。这样的设计可以保证AppExtension在运行时与ContainingApp隔离,不依赖于App。即使Extension在运行,ContainingApp也不会主动运行,ContainingApp和HostApp之间也没有通信。但是在实际的应用场景中,还是需要和ContainingApp进行通信的。这里系统给出的解决方案是两者之间使用共享存储来解决数据通信的问题。AppExtension需要打开ContainingApp,附加一些参数。可以通过OpenUrl实现。官方图解如下:详细的数据分享方法会在后续的Widget中详细介绍。初步了解了AppExtension之后,接下来就是详细分析Widget了。3.Widget简介Widget是一个可以添加到用户桌面或在“今日视图”中独立运行的程序。Widget的前身是TodayExtension,在iOS8.0中首次推出,在iOS14.0中被废弃,而Widget在iOS14.0中推出。其实两者有很大区别:从外观上看,TodayExtension只能添加到负一屏,只有展开和折叠两种尺寸。开发者可以自定义这部分区域的布局大小。小部件不仅可以添加到负一屏,还可以添加到桌面,与App并排,支持三种样式(小:2x2,中:4x2,大:4x4),这三种样式不支持自定义尺寸.Widget开发使用苹果新的WidgetKit,UI开发只能使用SwiftUI,TodayExtension使用UIKit。因此,Widget开发需要具备Swift和SwiftUI的技术知识。Xcode12不再提供TodayExtension的添加。现有的TodayExtensionApp,系统仍然显示在负一屏预留区域,不能像Widgets一样随意进行拖拽、移动、删除等操作,只保留原有的规则。三种大样式的显示效果:圆角部分是系统自带的三种尺寸在不同设备上的实际渲染尺寸。官网数据截图如下:iPhoneiPad门票目前只需要支持小卡和中卡两种款式。4.Widget开发框架介绍4.1单个/多个Widget配置单个和多个Widget在实际代码中的入口是不同的。单个Widget需要实现Widgetprotocol@mainstructWidget1:Widget{letkind:String="widgetTag"varbody:someWidgetConfiguration{...}}多个Widget需要实现WidgetBundleprotocol@mainstructTripWidgets:WidgetBundle{@WidgetBundleBuildervarbody:someWidget{Widget1()Widget2()Widget3()...}}添加Widget的操作需要用户在系统添加Widget页面进行,会显示一些简单的信息供用户查看。显示信息的具体配置如下:}.configurationDisplayName("旅行灵感").description("下一段旅程,现在开始").supportedFamilies([WidgetFamily.systemSmall,WidgetFamily.systemMedium])}}4.2Widget整体结构1)每个Widget需要返回一个WidgetConfiguration,它分为两种类型:EditablewidgetIntentConfigurationNon-editableStaticConfiguration2)每个WidgetConfiguration都需要一个Provider和一个ViewContent。Provider用于刷新数据层。主要有三个功能:placeholder(用于返回默认显示的数据Model)getSnapshot(用于调用和添加widget时渲染UI显示)getTimeline(用于添加数据和UI刷新)ViewContent用于UI显示,分分为三种尺寸:2x2(Small)、4x2(Medium)、4x4(Large)API整体架构系列图:4.3Widget刷新策略需要系统管理,为此定义了刷新规则。基本原理是提交一组数据给系统,用于以后刷新UI,每条数据都绑定时间,然后系统根据时间点将预设的数据渲染给用户。Provider定义如下:publicprotocolTimelineProvider{associatedtypeEntry:TimelineEntrytypealiasContext=TimelineProviderContextfuncplaceholder(incontext:Self.Context)->Self.EntryfuncgetSnapshot(incontext:Self.Context,completion:@escaping(Self.Entry)->Void)funcgetTimeline(incontext:Self.Context,completion:@escaping(Timeline)->Void)}Timeline结构如下:publicstructTimelinewhereEntryType:TimelineEntry{publicletentries:[EntryType]publicletpolicy:TimelineReloadPolicypublicinit(entries:[EntryType],policy:TimelineReloadPolicy)}构造Timeline条目的参数:[EntryType]做数据和时间绑定,自定义数据实体需要遵守TimelineEntry协议。TimelineEntry的具体实现需要一个日期和一个数据。TimelineEntry定义如下:publicprotocolTimelineEntry{vardate:Date{get}varrelevance:TimelineEntryRelevance?{get}}policy:TimelineReloadPolicy刷新策略TimelineReloadPolicy是一个配置对象,负责决定下一次的更新策略。系统使用Provider的getTimeline对数据刷新操作进行回调。该方法中,开发者提交获取的数据,封装成一个TimelineEntry,通过Timeline刷新策略提交给系统,最终实现刷新。系统在此提供了以下三种刷新策略的方式:1)atEnd,根据条目中给出的所有日期和数据执行刷新操作后,再次调用getTimeline更新刷新策略。2)after,用于指定未来某个时间,调用getTimeline更新刷新策略。3)never,添加执行一次后,策略刷新不再执行。4.4App与Widget关联互通1)Widget与App数据关联遵循AppExtension规范,系统提供NSUserDefaults和NSFileManger进行数据共享。前提是开启AppGroups功能。NSUserDefaults方法//保存NSUserDefaults*userDefaults=[[NSUserDefaultsalloc]initWithSuiteName:@"group.xxx.xxx.xx"];[userDefaultssetObject:@"test_content"forKey:@"test"];[userDefaultssynchronize];//GetNSUserDefaults*userDefaults=[[NSUserDefaultsalloc]initWithSuiteName:@"group.xxx.xxx.xx"];NSString*content=[userDefaultsobjectForKey:@"test"];NSFileManger//保存NSURL*containerURL=[[NSFileManagerdefaultManager]containerURLForSecurityApplicationGroupIdentifier:@"group.xxx.xxx.xx"];containerURL=[containerURLURLByAppendingPathComponent:@"testfile"];[datawriteToURL:containerURLatomically:YES];//获取NSURL*containerURL=[[NSFileManagerdefaultManager]containerURLForSecurityApplicationGroupIdentifier:@"group.xxx.xxx.xx"];containerURL=[containerURLURLByAppendingPathComponent:@"testfile"];NSData*value=[NSDatadataWithContentsOfURL:containerURL];2)当App的信息发生变化时,主动刷新Widget,系统提供实现如下:WidgetCenter.shared.reloadTimelines(ofKind:"widgetTag")3)Widget用UnviersalLinks/URLSchema唤醒App跳转,控制可以通过以下两个配置实现:widgetURL(小卡只支持全区点击)Link(小卡不支持,medium和large卡片可以支持局部跳转)当卡片打开时,会调用App的以下生命周期方法。如果需要跳转到特定页面,可以在这里做路由。funcscene(_scene:UIScene,openURLContextsURLContexts:Set){//URLContexts.first?.url.absoluteString....}五、项目开发经验总结一般来说,一个Widget可以根据官方的开发文档快速实现,但是在实际开发中总会遇到一些局限和问题。以下是我们在项目开发过程中遇到的一些问题和限制的总结。5.1Widget数量限制官方文档表示每个App最多可以配置5种Widget,可以在App中添加多个WidgetExtensiontarget,也可以在一个WidgetExtensiontarget中添加多个Widget。每个Widget最多支持三种样式:systemSmall、systemMedium、systemLarge,总共最多可以添加15种Widget到桌面。每个Widget可以添加多次,具体取决于用户操作。(实际测试本地模拟器环境超过5个,实际发布未验证。)5.2并非所有SwiftUI组件都可以使用。WidgetKit限制了WidgetUI由SwiftUI实现,但并不是所有的SwiftUI组件都可以被Widgets使用。如果遇到不支持的组件,WidgetKit会在渲染时忽略它。具体可以使用的组件,参见官方文档。5.3图片加载问题由于系统提供的机制需要提前预置数据,所以我们一开始尝试用App一样的方式加载图片控件,但是发现图片加载不出来。原因是这里不能异步,需要同步获取图片。另外这里的图片也不容易过大,也会影响加载。具体大小取决于当时系统的处理能力。(实测遇到200k图片无法加载的情况)5.4Widget点击事件小卡片只支持widgetURL,整个卡片区域只能响应一个事件。中卡和大卡可以支持Link,可以支持多区域点击。单击未设置widgetURL和Link的区域将默认调用ContainingApp。点击Widget的Widget和Link方法只能打开主ContainingApp。即使该URL维护了其他App的Schema,其他App也打不开。5.5代码分享注意事项官方介绍强调,分享代码时,导入的API必须AppExtension支持,否则审核时会被拒绝。标有NS_EXTENSION_UNAVAILABLE的SharedApplication的相关API(HealthKit,iOS8.0中的EventKitUI)访问摄像头/麦克风(iMessage除外)执行长期后台任务使用AirDrop接收数据(可以发送数据)详见使用嵌入式框架共享Code5.6刷新次数限制虽然系统提供了这些刷新方案,但实际运行次数会有一定的限制和出入。策略刷新频率至少间隔5分钟(小于这个间隔可能会不准确。虽然刷新机制提供了API支持,但实际刷新还是由系统控制的,并不是你每次添加的刷新都能起作用准确)。为了降低负载,系统在此基础上做了一层机器学习。实际刷新会根据widgets在用户手机上的可见频率和时间、上次刷新时间、主app的activity状态动态分配。5.7系统主动刷新机制同时,以下系统行为引起的刷新不计入刷新次数:Widget对应的应用在前台有活跃的音频或导航会话Widget对应的应用有活动的音频或导航会话。或者更改accessibility设置5.8Size问题Widget最终编译成一个二进制文件,后缀为.appex,和AppExtension一样,在ipa里面,所以和主App共享大小。5.9热修暂时没有热修计划,需要做好线上测试和bottom-up逻辑的处理。