问题背景
苹果 WWDC2023 Widgets 增加了新的交互能力,iOS17 以前 Widgets 只能展示,无法响应 UI 上所有的按钮事件,只能跳转到主 App 中。iOS17 之后,通过这个新的交互能力,就可以实现 Widgets 上面的按钮点击事件。如果感兴趣可以看下苹果的WWDC视频和官方文档,下面会简单的介绍下苹果实现灵动岛 (LiveActivity Widget) 上按钮响应的方式和开发的过程中遇到的问题。
1. 灵动岛如何增加按钮响应事件
根据苹果的 Widgets 的逻辑,也就是我们写的所有 SwiftUI 的代码会放在 Widgets Extension 的进程中,但是在 SwiftUI 代码中所有的异步事件都无法执行,例如按钮事件、下载事件等等。可以看下在 Widgets Extension 中编写下面的代码,print("test")
这行代码是没法执行的。
DynamicIslandExpandedRegion(.bottom) {
Button("Start") {
print("test")
}
}
猜测苹果在 Extension 进程中,把所有异步事件注册的能力给拔除了。
那 iOS17 之后灵动岛的 Widgets 又是怎么来响应异步事件呢?苹果是用 Intent 的方式来执行的,这样需要注册一个 Intent 类,然后来执行事件的代码。示例中按钮注册的代码如下:
DynamicIslandExpandedRegion(.bottom) {
Button(intent: ConfigurationAppIntent()) {
Image(systemName: "plus.circle.fill")
}
}
在接收的模块需要实现一个 Intent 的类,如下:
struct ConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Configuration"
static var description = IntentDescription("This is an example widget.")
func perform() async throws -> some IntentResult {
ShareData.updateButtonStatus()
return .result()
}
}
按照上述的方式,按钮点击时,就可以执行 updateButtonStatus
这个方法了,下面是 updateButtonStatus
这个方法执行的逻辑,就是在共享的 group 中存储数据,然后主进程就可以读取数据的变化了。
public struct ShareData {
static let appGroup = "com.mengtnt.myGroup"
private static var currentActivity: Activity<DynamicIslandAttributes>? = nil
public static func value() -> Int {
guard let result = UserDefaults(suiteName: appGroup)?.value(forKey: "value") as? Int else {
return 0
}
return result
}
public static func updateButtonStatus() {
var result = value()
result += 1
UserDefaults(suiteName: appGroup)?.setValue(result, forKey: "value")
}
}
这里展示下运行效果。
2. 遇到事件无响应的问题
由于钉钉都是基于 Framework 开发的,并不是全源码编译。所以这里模拟了钉钉的方式,写了如下的工程。所有的 Intent 和 LiveActivity 的代码都放到了一个叫 Appintent 的 Framework 下。然后相应的类引用这个 Framework,这个工程中任何源码都没改动。
然后神奇的一幕就发生了,灵动岛上的按钮事件没有任何反应。并且相应的代码也不会调用。效果如下:
然后推测是不是用 Framework 的方式,苹果把 Intent 的代码调用给裁剪掉了,然后用 hopper 分别打开了两种方式下生成的最终产物,分别搜索按钮响应事件调用的 updateButtonStatus
方法.
然后通过 hopper 跳转到调用的函数地址,就会发现调用的函数都是 Intent 类的 perform 方法。所以 Intent 类的 perform 方法其实都已经 link 到了目标 Target 中,只是两种方式生成的符号地址不一样而已。
所以应该不是先前的猜测,用 Framework 的方式执行的目标代码应该没有被裁剪。那么执行的代码都生成了,又会是什么原因造成按钮事件无响应呢?
3. 找出 Intent 调用的真相
然后就继续对比了下全源码编译的产物和 Framework 方式编译的最终产物。发现全源码编译在 Extension Target 下面多了这个文件夹 MetaData.appintents,但是 Framework 方式并没有。
打开这个文件,发现里面有一个 extract.actionsdata 的 json 文件,里面注册了一个 mangledTypeName 名字。
{
"generator": {
"name": "xcode-tools",
"version": "15.0"
},
"enums": [],
"queries": {},
"autoShortcuts": [],
"version": 1,
"entities": {},
"negativePhrases": [],
"actions": {
"ConfigurationAppIntent": {
"outputFlags": 0,
"isDiscoverable": true,
"typeSpecificMetadata": [],
"requiredCapabilities": [],
"effectiveBundleIdentifiers": [],
"parameters": [],
"systemProtocolMetadata": [
"com.apple.link.systemProtocol.WidgetConfiguration",
{
"empty": {}
}
],
"descriptionMetadata": {
"searchKeywords": [],
"descriptionText": {
"key": "This is an example widget."
}
},
"mangledTypeNameByBundleIdentifier": {},
"title": {
"key": "Configuration"
},
"identifier": "ConfigurationAppIntent",
"systemProtocols": [
"com.apple.link.systemProtocol.WidgetConfiguration"
],
"authenticationPolicy": 0,
"presentationStyle": 0,
"mangledTypeName": "22DynamicIslandExtension22ConfigurationAppIntentV",
"availabilityAnnotations": {
"LNPlatformNameWildcard": {
"introducedVersion": "*"
}
},
"openAppWhenRun": false
}
},
"shortcutTileColor": 14
}
mangled type name 又是什么,简单的讲就是编译器根据用户的代码生成的特殊符号,里面包含了很多信息例如类型、接收的参数,返回类型,模板的情况等等。如果感兴趣可以详细了解 Swift 语言关于 mangled type 的原理。说白了 mangledTypeName 就是在编译器 link 的时候,方便获取函数调用的真正地址。用 hopper 打开,搜索定义的符号名称 22DynamicIslandExtension22ConfigurationAppIntentV
。这个名字正是前面看到的 Intent 类的地址符号。
通过此 mangledTypeName 就可以找到类相应的内存地址,从而找到执行的 perform 方法。
那通过同样的方式,就寻找了下 Framework 方式下的 Extension 的产物,然后使用 hopper 打开,寻找相应的 Intent 类的符号,发现为 9AppIntent013ConfigurationaB0V
,如下图,这里可以看出来这个符号是带有 Framework 名称 AppIntent。
然后用类似的方式,在 Extension Target 下面创建了 metadata.appintents 文件夹,然后配置了如下数据:
{
"generator": {
"name": "xcode-tools",
"version": "15.0"
},
"enums": [],
"queries": {},
"autoShortcuts": [],
"version": 1,
"entities": {},
"negativePhrases": [],
"actions": {
"ConfigurationAppIntent": {
"outputFlags": 0,
"isDiscoverable": true,
"typeSpecificMetadata": [],
"requiredCapabilities": [],
"effectiveBundleIdentifiers": [],
"parameters": [],
"systemProtocolMetadata": [
"com.apple.link.systemProtocol.WidgetConfiguration",
{
"empty": {}
}
],
"descriptionMetadata": {
"searchKeywords": [],
"descriptionText": {
"key": "This is an example widget."
}
},
"mangledTypeNameByBundleIdentifier": {},
"title": {
"key": "Configuration"
},
"identifier": "ConfigurationAppIntent",
"systemProtocols": [
"com.apple.link.systemProtocol.WidgetConfiguration"
],
"authenticationPolicy": 0,
"presentationStyle": 0,
"mangledTypeName": "9AppIntent013ConfigurationaB0V",
"availabilityAnnotations": {
"LNPlatformNameWildcard": {
"introducedVersion": "*"
}
},
"openAppWhenRun": false
}
},
"shortcutTileColor": 14
}
运行后,果然按钮事件可以响应了,和全源码编译的运行结果一模一样。
4. 总结
通过上面的尝试,我们可以总结出来苹果 Intent 的运行原理,其实有点像 patch 原理,把异步事件响应的代码,用 metaData.appintents 中定义的符号地址替换,用户运行时就执行了 metaData.appintents 中定义的 action 方法。
其实苹果在开发者文档的中描述过 Intent 的大致执行过程,但是具体的实现方式并没有描述。
通过进一步验证了,把 Intent 类放到了主 App 的 Target 中,编译后,在主进程的 Target 中同样会生成一个 metaData.appintents,然后运行后,事件响应的代码就在主进程的空间中执行了。
所以总结下,Widgets 的整个生命周期的事件循环,本质上都是系统托管的。然后如何执行用户的异步事件呢?就像上面说的,在编译 Widgets 代码的时候,会根据注册的异步事件,然后生成 Intent 类的符号地址,然后会把此符号地址放到 metaData.appintents 文件夹下 extract.actionsdata 这个 json 文件中。当异步事件调用时,系统就会替换为 Intent 类注册的 perform 方法。不过发现 xcode 在编译 Widgets Extension 时,如果 Target 源码中没有 Intent 这个类就不生成调用地址的符号数据了,从而造成使用 Framework 这种方式,无法响应异步事件,不知道是苹果特意这样设计的,还是给我们开发者埋的小惊喜😂。