干货 | 携程机票iOS Widget实践

iOS 14 Widget开发实战:从AppExtension到WidgetKit的探索

作者简介

 

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)。如下图:

f1c76a3e0ff665a317925f4a34effdf3.png

2.2 App Extension工作原理

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

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

官方简介图:

8ea3c6525777e37bcc47e9e7876c5c12.png

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

be2552690e5c59127c2bceed8a1ed023.png

由图可知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的方式来实现。

如下官方图示说明:

2d2e53978c15b27632c7c0304c34c6f3.png

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

三、Widget简介

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

035852cb31bda5ac4d95fb1821799b56.png

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一样随意拖动移动位置和删除等操作,仅保留最初的规则

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

3bf956d15af1cf26bcbf004953772e7b.png

圆角为系统自带

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

iPhone

e33f7ebee80a87760f33982e59be3893.png

iPad 

a7dae237dc68704e0dcf25756bd59c45.png

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

四、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的添加操作需要用户在系统添加小组件页面进行,该页面会展示一些简单信息供用户查看。

a9f403d7d14e57b350354e3a86c2a618.png

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

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

  • • 不可编辑 StaticConfiguration

2) 每个WidgetConfiguration都需要一个Provider和一个ViewContent。

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

  -placeholder (用于返回默认展示的数据Model)

  -getSnapshot(用于渲染呼出添加小组件时的UI展示)

  -getTimeline(用于添加到用户桌面后的数据和UI刷新)

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

API整体架构串联图:

6ba265008eb9d437b7f8891b527b321c.png

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

    694e7165982d4a8507fc81393f90215a.png

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

    f637a2a0f73f72bf2667856242247b1a.png

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

4.4 App和Widget关联&互操作

  1. 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];
  1. 2)App的信息改变主动刷新Widget,系统提供了如下方式实现:

WidgetCenter.shared.reloadTimelines(ofKind: "widgetTag")
  1. 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 热修复问题

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

参考文献

【推荐阅读】

6d5509c8cef50ae18b23ca781b14d446.png

 “携程技术”公众号

  分享,交流,成长

<think>嗯,用户这次的问题很明确——想阻止谷歌浏览器每次启动时自动弹出配置窗口。结合之前的对话,用户已经尝试过一些配置操作(比如引用[2]提到的快捷方式参数修改),但似乎遇到了新的困扰。用户提供的四个引用中,引用[2]关于全屏启动的内容其实已经隐含了关键线索——通过快捷方式参数控制浏览器行为。而引用[4]提到的端口参数化方法也验证了这种思路的通用性。不过用户真正需要的不是添加功能而是关闭某功能,这需要逆向思维。深层需求可能是:用户在使用某些企业级或教育版Chrome时遇到强制配置弹窗(常见于学校机房或公司电脑),或者安装某些插件后导致浏览器行为异常。考虑到用户之前研究过跨域配置等开发者功能,应该有一定技术基础。解决方案的核心在于识别弹窗来源:1)如果是Chrome自身更新引导,清理注册表就能解决2)如果是第三方程序注入,需要检查启动项3)如果是组策略限制(企业环境常见),则需要更高权限操作最安全的切入点是从用户权限最高的层面开始排查——先检查快捷方式参数是否被植入启动配置页的指令。这里有个细节:配置页的URL通常是chrome://settings/onStartup,而强制跳转可能通过--launch-on-startup=force-url这类参数实现。用户没提操作系统类型,但引用[2]显示ta熟悉Windows注册表操作,所以优先给Windows方案。注册表路径HKEY_CURRENT_USER\Software\Policies下的chrome子项要特别检查,企业环境常用这里部署强制策略。最后那个临时退出技巧是实战经验:当用户被迫进入配置页时,在地址栏手动跳转about:blank可以立即打断引导流程,比强行关闭进程更优雅。</think> 根据您的需求,这是管理谷歌浏览器配置窗口的完整解决方案,按优先级排序操作: ### 一、关闭浏览器自动配置(根源处理) 1. **修改快捷方式属性**(推荐首选) - 右键浏览器快捷方式 → 选择「属性」 - 在「目标」栏删除类似以下参数: ```bash --force-first-run --no-first-run ``` - ✅ 正确格式示例(参数末尾有空格): ```bash "C:\Program Files\Google\Chrome\Application\chrome.exe" --kiosk ``` 2. **清理注册表残留**(Windows系统专用) - 按 `Win+R` 输入 `regedit` - 导航路径: ``` HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Google\Chrome ``` - 删除右侧 `ForceFirstRun` 和 `RestoreOnStartup` 键值 - 检查路径: ``` HKEY_CURRENT_USER\Software\Policies\Google\Chrome ``` ### 二、修改默认启动行为(直接生效) 1. **设置默认启动页** - 浏览器地址栏输入 `chrome://settings/onStartup` - 选择「打开特定页面」→ 添加 `about:blank`(空白页) 2. **配置文件重置**(终极方案) - 访问 `chrome://settings/reset` - 点击「将设置还原为原始默认值」→ 确认重置 > ⚠️ 会清除扩展、主题等个性化配置[^3] ### 三、解决特殊情况 ▶ **企业环境管控** 在组策略编辑器中(`gpedit.msc`)禁用: ``` 计算机配置 → 管理模板 → Google → Google Chrome → 禁用「显示首次运行体验」 ``` ▶ **临时强制退出技巧** 启动浏览器时出现配置页→在地址栏输入并访问: ``` chrome://quit ``` 立即关闭所有浏览器进程 --- ### 操作验证表 | 操作步骤 | 是否需重启 | 生效概率 | |---------------------------|-------------|----------| | 快捷方式参数清理 | 否 | 95% | | 注册表清理 | 是 | 80% | | 设置默认启动页 | 否 | 90% | | 重置浏览器配置 | 是 | 100% | > 📌 最佳实践:先用「快捷方式参数清理」+「设置默认启动页」,无效时再执行重置操作 ---
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值