初探 SwiftUI Link
起 - Link
最近在使用 Link 的时候发现在 watchOS 上无法成功打开链接,而是会弹窗提示需要在 iPhone 端打开
虽然后续发现可以通过 ASWebAuthenticationSession 来绕过打开,但还是对 Link 的实现以及我们能使用 Link 做什么产生了好奇。
#if os(watchOS)
import AuthenticationServices
/// A workaround to open link in watchOS platform
struct WatchLink<Label>: View where Label: View {
init(destination: URL, @ViewBuilder label: () -> Label) {
self.destination = destination
self.label = label()
}
let destination: URL
let label: Label
var body: some View {
Button {
let session = ASWebAuthenticationSession(
url: destination,
callbackURLScheme: nil
) { _, _ in
}
session.prefersEphemeralWebBrowserSession = true
session.start()
} label: {
label
}
}
}
typealias Link = WatchLink
#endif
通过 Swift 的反射机制和查看 SwiftUI.swiftinterface等方式,我们可以得到关于 Link 的以下信息
public struct Link: View {
var label: Label
var destination: LinkDestination
public var body: some View {
Button {
destination.open()
} label: {
label
}
}
}
struct LinkDestination {
struct Configuration {
var url: URL
var isSensitive: Bool
}
var configuration: Configuration
@Environment(\.openURL)
private var openURL: OpenURLAction
@Environment(\._openSensitiveURL)
private var openSensitiveURL: OpenURLAction
func open() {
let openURLAction = configuration.isSensitive ? openSensitiveURL : openURL
openURLAction(configuration.url)
}
public init(destination: URL, @ViewBuilder label: () -> Label) {
self.label = label()
self.destination = LinkDestination(configuration: .init(url: destination, isSensitive: false))
}
}
简单的说 Link 的本质就是通过 @Environment(\.openURL)
来拿到 OpenURLAction 进行唤起打开。
引起我注意的是这里的 @Environment(\._openSensitiveURL)
,目前 SwiftUI 暴露出来的 Link API 中并没有将 isSensitive
设置为 true 的情况。
那我们需要如何测试/使用 openSensitiveURL
呢?
承 - openSensitiveURL
第一种方式比较直观:既然 LinkDestination 内部在使用 @Environment(\._openSensitiveURL)
,说明 SwiftUI 内部是有类似的如下代码,只是可能没有暴露给外部第三方使用
extension EnvironmentValues {
var _openSensitiveURL: OpenURLAction {
get { ... }
set { ... }
}
}
我们直接修改 SDK 中对应的 SwiftUI.swiftinterface 添加上这个 API 即可 (事实上,对于这里的 _openSensitiveURL 而言,SwiftUI.swiftinterface 已经将其标记为了 public,因此我们不需要进行任何修改即可使用)
// SwiftUI.swiftinterface
@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
extension SwiftUI.EnvironmentValues {
public var openURL: SwiftUI.OpenURLAction {
get
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
set
}
public var _openURL: SwiftUI.OpenURLAction {
get
set
}
}
extension SwiftUI.EnvironmentValues {
@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
public var _openSensitiveURL: SwiftUI.OpenURLAction {
get
set
}
}
拿到这个 API 后我们就可以直接调用 OpenURLAction.callAsFunction(_:)
来进行测试
第二种方式更加通用:我们通过 Unsafe Swift 提供的指针操作直接修改 isSensitive
先将 SwiftUI.Link
对象转为 UnsafeMutable*Pointer
,再将其 cast 到我们自定义对齐 SwiftUI.Link
布局的 DemoLink
类型,最后完成修改,并返回原 link 对象给到 SwiftUI 系统
let url = URL(string: "https://example.com")!
var link = SwiftUI.Link(destination: url) {
SwiftUI.Text("Example")
}
withUnsafeMutablePointer(to: &link) { pointer in
let linkPointer = UnsafeMutableRawPointer(pointer).assumingMemoryBound(to: DemoLink<DemoText>.self)
let isSensitivePointer = linkPointer.pointer(to: \.destination.configuration.isSensitive)!
isSensitivePointer.pointee = true
}
return link
转 - OpenSensitiveURLActionKey
测试后发现对于普通的 url(比如 https://example.com
),openURL
和 _openSensitiveURL
都能打开,但是对于敏感 URL (比如设置隐私页schema prefs:root=Privacy
)
二者还是都无法打开,对应错误日志如下
Failed to open URL prefs:root=Privacy&path=contacts:
Error Domain=NSOSStatusErrorDomain
Code=-10814 "(null)"
UserInfo={
_LSLine=225,
_LSFunction=-[_LSDOpenClient openURL:options:completionHandler:]
}
二者在 iOS 平台的实现分别走了 UIApplication 的 open 方法和 LSApplicationWorkspace 的 open 方法
struct OpenURLActionKey: EnvironmentKey {
static let defaultValue = OpenURLAction(
handler: .system { url, completion in
UIApplication.shared.open(url, options: [:], completionHandler: completion)
},
isDefault: false
)
}
struct OpenSensitiveURLActionKey: EnvironmentKey {
static let defaultValue = OpenURLAction(
handler: .system { url, completion in
let config = _LSOpenConfiguration()
config.isSensitive = true
let scene = UIApplication.shared.connectedScenes.first
config.targetConnectionEndpoint = scene?._currentOpenApplicationEndpoint
guard let workspace = LSApplicationWorkspace.default() else {
return
}
workspace.open(url, configuration: config, completionHandler: completion)
},
isDefault: false
)
}
对于前者无法打开是符合预期的,对于后者继续跟踪定位发现是由于我们缺少了 com.apple.springboard.opensensitiveurl
这项 entitlement 导致的
添加该 entitlement 后在模拟器上运行,openSensitiveURL(url) 即可成功跳转到设置隐私页。
链接 Private Framework
OpenSensitiveURLActionKey 在 iOS 上依赖大量私有 API,正常的模拟实现建议通过 ObjectiveC Runtime 的消息机制(NSStringFromClass performSelector)来实现。
对于 macOS 平台,我们可以通过添加相关 flag 和添加系统库的搜索路径来比较简单地完成对 Private Framework 的 link
(例子可以参考之前写的macOS 提取 Keychain 数据的 Package)
但是 iOS 平台的私有库在我们的编译环境 macOS 上并不存在,导致添加相关 header 封装成 framework 后可以顺利 build,但是无法完成 link
OpenSwiftUI 这里对 iOS 的 OpenSensitiveURLActionKey 实现使用了 macOS 方案并且可以正常工作只是巧合
- 依赖的 CoreServices 在 macOS 上刚好有同样的私有库和对应实现
- 依赖的 BoardServices 中的某个类只用作参数传递,使用 id/Any 即可
- 依赖的 UIKitCore 的 UIScene 在公开 SDK 的 UIKit 中有定义,这里只需扩展方法,无需link UIKitCore
合 - 结论
回到本文最初的问题,watchOS 上 Link 的问题是因为 OpenURLActionKey.defaultValue
在 watchOS 上的实现导致的
相比 typealias Link = WatchLink
我们通过覆盖 @Environment(\.openURL)
可以更好地解决原问题
- 无缝使用 Link 的 其他API,而不是给 WatchLink 都重复实现一次
- 享受 Link 自带的无障碍支持(或者使用 accessibilityRepresentation 将无障碍行为转发到 Link)
- 基于运行时的 Environment 体系,而非编译期的 typealias
#if os(watchOS)
import AuthenticationServices
extension OpenURLAction {
static let authenticationSessionAction = OpenURLAction {
let session = ASWebAuthenticationSession(
url: $0,
callbackURLScheme: nil
) { _, _ in
}
session.prefersEphemeralWebBrowserSession = true
session.start()
return .handled
}
}
#endif
struct ContentView: View {
var body: some View {
...
#if os(watchOS)
.environment(\.openURL, .authenticationSessionAction)
#endif
}
}
通过本文的对 SwiftUI Link 的探索,希望能为不熟悉 SwiftUI.Link 的同学带来一点点帮助
参考
- 本文相关的示例代码 - Kyle-Ye/SwiftUILinkDemo
- Link 的相关完整代码可以参考该 PR - Kyle-Ye/OpenSwiftUI#5
- 更多的 sensitiveURL 可以参考这篇文章 - Complete List of iOS URL Schemes for Apple Settings