携程机票iOS Widget实践

语言: CN / TW / HK

作者 | Derek Yang,携程资深研发经理,专注于iOS开发&跨端技术研究,热衷于新技术探索。

一、前言

2020年9月苹果发布了iOS 14.0,相较之前有了很大的功能改观,很重要的一点是用户可以更加个性化的定义自己的桌面,Widget就是这项功能的主角。

近期接到一项产品需求,需要实现若干机票业务相关的Widget,此文总结该需求开发上线过程中的踩坑填坑经验。

Widget俗称小组件,是苹果推出的众多App Extension中的一款。因此在介绍Widget之前,需要先了解App Extension及其工作原理。

二、App Extension简介

iOS 8.0开始,就支持了App Extension的开发来满足丰富App的需要。

2.1 什么是App Extension?

App Extension顾名思义是应用扩展。所以它不是一个应用程序,而是实现一个特定的、范围明确的自定义任务。

这个任务由开发人员自定义,并遵循系统规范的扩展策略,在用户与其他应用或者系统交互时将其提供给用户。

App Extension编译后是一个后缀名为.appex的二进制文件,无法独立分发和安装,必须依附于App。

一个 App 可以挂载多个种类的App Extension。截止目前为止,苹果已经陆续推出33款App Extension,常见的有照片编辑(Photo Editing)、共享(Share)、自定义键盘(Custom Keyboard),小组件(Widget)。如下图:

2.2 App Extension工作原理

App Extension的生命周期与常规App不同,需要一个包含Extension的App(Containing App),以及唤起Extension的App(Host App)。

当用户通过Host App唤起Extension时,系统实例化Extension,从此Extension的生命周期开始,Extension开始执行自己的任务。之后当任务执行结束或者用户通过Host app结束任务时,或者系统由于某种原因将其进程结束,Extension的生命周期到此结束。

官方简介图:

Extension、Containing App和Host App三者之间的通信关系,如下官网图示:

由图可知App Extension与Host App可以直接通信,而App Extension和Containing App之间并不直接通信。

这样设计可以保证App Extension在运行时与Containing App隔离,不依赖于App,甚至在Extension在运行时,Containing App都不会主动运行,Containing App和Host App两者间没有通信。

但是在实际应用场景中,仍然会有和Containing App通信的需求,这里系统给出的方案是在两者之间使用共有存储来解决数据通信的问题,App Extension需要打开Containing App 并附带一些参数,则可以通过Open Url的方式来实现。

如下官方图示说明:

详细的数据共享方式将在后续Widget的篇幅中详细介绍。初步了解App Extension后,接下来详细分析Widget。

三、Widget简介

Widget是能添加到用户桌面或者在“今日视图"中独立运行的程序。

Widget前身是Today Extension,其在iOS 8.0第一次推出,在iOS 14.0被废弃,Widget于iOS 14.0推出。实际两者有较大的区别:

外观上Today Extension只能添加到负一屏,只有展开和收起两种尺寸,开发人员可以自定义这部分区域的布局大小。Widget不仅可以添加到负一屏,还可以添加到桌面,和App并列,同时支持三种样式(小:2x2、中:4x2、大:4x4),这三种样式不支持自定义尺寸。

Widget开发使用苹果新推出的WidgetKit,UI开发只能使用SwiftUI,而Today Extension则使用UIKit。因此进行Widget开发,需要Swift和SwiftUI的技术知识。

Xcode12不再提供Today Extension的添加,对于已有Today Extension的App,系统仍然在负一屏保留的区域展示,并且不能像Widget一样随意拖动移动位置和删除等操作,仅保留最初的规则

小中大三种样式的展示效果:

圆角为系统自带

三种尺寸在不同设备上的实际渲染尺寸,如下官网数据截图:

iPhone

iPad

机票当前需求仅需支持小卡、中卡两种样式。

四、Widget的开发框架简介

4.1 单/多个widget配置

单个和多个Widget在实际代码中的入口不同。

单个 widget 需要实现 Widget protocol

@main
struct Widget1: Widget {
    let kind: String = "widgetTag"
    var body: some WidgetConfiguration {
        ...
    }
}

多个 Widget 需要实现 WidgetBundle protocol

@main
struct TripWidgets: WidgetBundle {
    @WidgetBundleBuilder
    var body: some Widget {
        Widget1()
        Widget2()
        Widget3()
        ...
    }
}

Widget的添加操作需要用户在系统添加小组件页面进行,该页面会展示一些简单信息供用户查看。

展示信息的具体配置如下:

struct Widget1: Widget {
    let kind: String = "widgetTag"
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            Widget1View(entry: entry)
        }
        .configurationDisplayName("旅行灵感")
        .description("下段旅程,即刻启程")
        .supportedFamilies([WidgetFamily.systemSmall,WidgetFamily.systemMedium])
    }
}

4.2 Widget整体结构

1)每个Widget都需要返回一个WidgetConfiguration,分为两种:

  • 可编辑的小组件 IntentConfiguration
  • 不可编辑 StaticConfiguration2) 每个WidgetConfiguration都需要一个Provider和一个ViewContent。

Provider用于做数据层刷新,主要有三个function:

  • placeholder (用于返回默认展示的数据Model)
  • getSnapshot(用于渲染呼出添加小组件时的UI展示)
  • getTimeline(用于添加到用户桌面后的数据和UI刷新)

ViewContent用于UI展示,分三种大小:2x2(Small)、4x2(Medium)、4x4(Large)

API整体架构串联图:

4.3 Widget刷新策略

由于Widget是用户添加到用户桌面的,刷新也需要系统管理,系统为此定义了一个刷新规则。通过Provider的getTimeline来实现,基本原理是给系统提交一组未来时间内用于刷新UI的数据,每个数据与时间绑定,然后系统根据时间点,将预设的数据渲染给到用户。

Provider定义如下:

public protocol TimelineProvider {
    associatedtype Entry : TimelineEntry
    typealias Context = TimelineProviderContext
    func placeholder(in context: Self.Context) -> Self.Entry
    func getSnapshot(in context: Self.Context, completion: @escaping (Self.Entry) -> Void)
    func getTimeline(in context: Self.Context, completion: @escaping (Timeline<Self.Entry>) -> Void)
}

Timeline结构如下:

public struct Timeline<EntryType> where EntryType : TimelineEntry {

    public let entries: [EntryType]

    public let policy: TimelineReloadPolicy
  
    public init(entries: [EntryType], policy: TimelineReloadPolicy)
}

构建Timeline的参数

entries:[EntryType] 做数据和时间绑定,自定义的数据实体需要遵守TimelineEntry的协议。

TimelineEntry的具体实现均需要一个date和一个数据。

TimelineEntry定义如下:

public protocol TimelineEntry {
    var date: Date { get }
    var relevance: TimelineEntryRelevance? { get }
}

policy:TimelineReloadPolicy 刷新策略

TimelineReloadPolicy是负责决定下一次更新策略的配置对象。

系统通过Provider的getTimeline来做数据刷新操作的回调,开发者在此方法中将获取的数据提交封装成TimelineEntry,并加上Timeline的刷新策略提交给系统,最终实现刷新。

此处刷新策略,系统给出了下面三种方式:

1)atEnd,按照entries中给到的所有日期和数据执行刷新操作后,再一次调用getTimeline来更新刷新策略。

2)after,用于指定未来的一个时间,调用getTimeline就更新刷新策略。

3)never,添加之后执行一次后,不再执行做策略刷新。

4.4 App和Widget关联&互操作

1)Widget和App的数据关联,遵循App Extension的规范,系统提供了NSUserDefaults和NSFileManger两种方式来做数据共享。前提都需要开启App Groups的功能。

NSUserDefaults方式

//存
NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.xxx.xxx.xx"];
[userDefaults setObject:@"test_content" forKey:@"test"];
[userDefaults synchronize];
//取
NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.xxx.xxx.xx"];
NSString *content = [userDefaults objectForKey:@"test"];

NSFileManger

// 存
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.xxx.xxx.xx"];
containerURL = [containerURL URLByAppendingPathComponent:@"testfile"];
[data writeToURL:containerURL atomically:YES];

//取
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.xxx.xxx.xx"];
containerURL = [containerURL URLByAppendingPathComponent:@"testfile"];
NSData *value = [NSData dataWithContentsOfURL:containerURL];

2)App的信息改变主动刷新Widget,系统提供了如下方式实现:

WidgetCenter.shared.reloadTimelines(ofKind: "widgetTag")

3)Widget唤醒App

以Unviersal Links /URL Schema跳转,控件采用如下两种配置即可实现:

  • widgetURL(小卡只支持整个区域的点击)
  • Link(小卡不支持,中卡和大卡可以支持局部区域的跳转)

卡片打开会调用App的如下生命周期方法,如需跳转到具体页面此处做路由即可。

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    //URLContexts.first?.url.absoluteString
    ....
}

五、项目开发经验总结

总体来讲按照官方开发文档就能快速实现一个Widget,但是实际开发中总会遇到一些限制和问题。下面是我们在项目开发中遇到的一些问题和限制的总结。

5.1 Widget的数量限制

官方文档表明每个App最多配置5种Widget,可以是App添加多个WidgetExtension的target,也可以是一个WidgetExtension的target中添加多种Widget,每种Widget最多支持三种样式:systemSmall,systemMedium,systemLarge,总共最多可添加15种Widget到桌面。

每种Widget可以被添加多次,这个取决于用户的操作。(实测本地模拟器环境可超过5种,实际发布上线未验证)

5.2 不是所有的SwiftUI组件都可用

WidgetKit限制Widget UI需由SwiftUI实现,但并不是所有SwiftUI的组件都可供Widget使用。如果遇到不支持的组件,WidgetKit渲染时会忽略。

具体可使用的组件参见官方文档。

5.3 图片加载问题

由于系统提供的机制是需要提前预设数据,我们最初尝试用像App一样的方式去加载图片控件,结果发现图片并不加载。原因是这里不能做异步,需要同步获取Image。

另外此处图片不易过大,也会影响加载,具体size取决于当时系统的处理能力。(实测遇到200k的图片无法加载的情况)

5.4 Widget点击事件

小卡只支持widgetURL,整个卡片区域只能做一个事件响应。中卡和大卡可支持Link,可支持多个区域的点击。点击未设置widgetURL和Link的区域,都会默认唤起Containing App。

点击Widget的Widget和Link方式,只能打开主Containing App,即使URL维护的是其他App的Schema,也是无法打开其他App的。

5.5 代码共享注意点

官方介绍在共享代码时强调引入的API必须是AppExtension支持的,否则在审核时会被拒。

  • SharedApplication的相关API
  • 带有NS_EXTENSION_UNAVAILABLE标记的(iOS 8.0中的HealthKit、EventKit UI)
  • 访问摄像头/麦克风(iMessage除外)
  • 执行长时间的后台任务
  • 用AirDrop接受数据(可发送数据)

具体参见 Using an Embedded Framework to Share Code

5.6 刷新次数的限制

虽然系统给出了这些刷新方案,但是在实际运行时次数上会有一定的限制和出入。

  • 策略刷新频率至少相隔5分钟(少于这个间隔可能会不准确,刷新机制虽然提供了API支持,但是实际刷新还是由系统掌控,并不是你添加的每次刷新都能准确的奏效)。
  • 系统为了减负,在这个基础上做了一层机器学习,实际的刷新会根据用户手机上小组件的可见频率时间、上次重新加载的时间以及主app的活动状态做动态分配。

5.7 系统主动刷新机制

同时系统以下这些行为导致的刷新,将不会被统计到到刷新次数中:

  • Widget对应的应用程序在前台
  • Widget对应的应用程序具有活动的音频或导航会话
  • 手机系统区域更改
  • 动态类型或辅助功能设置更改

5.8 Size问题

Widget最终编译为后缀名为.appex的二进制文件,这一点同AppExtension一样,并在ipa内部,故size和主App共享。

5.9 热修复问题

暂无热修方案,故需要做好上线的测试以及兜底逻辑的处理。