初探 SwiftUI Link

本文首发于个人博客 kyleye.top

初探 SwiftUI Link

最近在使用 Link 的时候发现在 watchOS 上无法成功打开链接,而是会弹窗提示需要在 iPhone 端打开

虽然后续发现可以通过 ASWebAuthenticationSession 来绕过打开,但还是对 Link 的实现以及我们能使用 Link 做什么产生了好奇。

swift 复制代码
#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 的以下信息

swift 复制代码
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 内部是有类似的如下代码,只是可能没有暴露给外部第三方使用

swift 复制代码
extension EnvironmentValues {
    var _openSensitiveURL: OpenURLAction {
        get { ... }
        set { ... }
    }
}

我们直接修改 SDK 中对应的 SwiftUI.swiftinterface 添加上这个 API 即可 (事实上,对于这里的 _openSensitiveURL 而言,SwiftUI.swiftinterface 已经将其标记为了 public,因此我们不需要进行任何修改即可使用)

swift 复制代码
// 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 系统

swift 复制代码
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

二者还是都无法打开,对应错误日志如下

ini 复制代码
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 方法

swift 复制代码
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 方案并且可以正常工作只是巧合

  1. 依赖的 CoreServices 在 macOS 上刚好有同样的私有库和对应实现
  2. 依赖的 BoardServices 中的某个类只用作参数传递,使用 id/Any 即可
  3. 依赖的 UIKitCore 的 UIScene 在公开 SDK 的 UIKit 中有定义,这里只需扩展方法,无需link UIKitCore

合 - 结论

回到本文最初的问题,watchOS 上 Link 的问题是因为 OpenURLActionKey.defaultValue 在 watchOS 上的实现导致的

相比 typealias Link = WatchLink 我们通过覆盖 @Environment(\.openURL) 可以更好地解决原问题

  • 无缝使用 Link 的 其他API,而不是给 WatchLink 都重复实现一次
  • 享受 Link 自带的无障碍支持(或者使用 accessibilityRepresentation 将无障碍行为转发到 Link)
  • 基于运行时的 Environment 体系,而非编译期的 typealias
swift 复制代码
#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 的同学带来一点点帮助

参考

相关推荐
羑悻的小杀马特5 小时前
iOS:重新定义移动交互,引领智能生活新潮流
macos·ios·objective-c·cocoa·mac
I烟雨云渊T7 小时前
iOS热更新技术要点与风险分析
ios
Digitally7 小时前
如何解决Move to iOS 不起作用的问题?
macos·ios·cocoa
安和昂15 小时前
iOS 内存分区
macos·ios·cocoa
Unlimitedz15 小时前
iOS解码实现
ios
安和昂1 天前
iOS 工厂模式
ios
龙湾开发1 天前
轻量级高性能推理引擎MNN 学习笔记 03.在iOS运行MNN的示例
c++·学习·ios·图形渲染·mnn
初遇你时动了情1 天前
flutter 配置 安卓、Ios启动图
android·flutter·ios
WDeLiang2 天前
Flutter - UIKit开发相关指南 - 线程和异步
flutter·ios·dart
Unlimitedz2 天前
iOS音视频解封装分析
ios·音视频