在網路上已經有很多關於 iOS Share Extension 的教學,例如這裡就有一篇。有需要的人可以自行在網路上搜尋,這邊就不多作著墨。我這次想要分享的主要是在開發 share extension 的過程遇到一些要注意的事項,以及踩到的一些坑。

我的開發環境是 macOS Sonoma + Xcode 15,使用 Swift 開發。

要建立一個 share extension 很簡單,就是在你的專案新增一個 target 然後選擇 Share Extension 即可。Xcode 會自動幫忙產生必要的檔案,以及做好基本設定。

設定入口畫面

Xcode 會自動產生 ShareViewController 這個檔案,在蘋果的規劃裡頭,它預期大部分 Share Extension 的情境就是要把內容轉發到其他網路平台,所以這個 ShareViewController 會繼承自 SLComposeServiceViewController 並且也有提供發文的程式碼骨架。如果這符合你的情境,那很棒;如果不是的話,你可以手動把它改成繼承自 UIViewController 然後刪除那些骨架程式碼。

Xcode 也會自動幫忙產生一個 MainInterface.storyboard 作為入口點。目前 storyboard 已經不被鼓勵使用,而且很多開發者也逐步轉向 SwiftUI 開發畫面,所以我們可以刪除這個檔案,然後在 ShareViewController 裡頭刻畫面。

要修改畫面入口點,除了刪除 storyboard 之外,還要修改 Info.plist 告訴 Xcode 我們的入口點是什麼。在 Share Extension 的 Info.plist 裡面,找到 NSExtensionMainStoryboard 這組 key,把它的名稱改成 NSExtensionPrincipalClass 並且把對應的 value 改成 $(PRODUCT_NAME).ShareViewController

設定要支援的分享內容格式

使用者有可能分享多種內容格式,最常見的就是「文字」、「圖片」、「網址」這幾種,當然還有各種不同的檔案類型等等。我們需要在 Info.plist 裡頭設定,告訴系統我們的 share extension 支援哪些內容格式,只有當使用者分享的內容是我們支援的,系統的分享選單才會出現我們的 share extension。

要支援的格式放在 NSExtensionActivationRule key 底下,支援的格式寫在這篇文件裡,如果預設的支援格式不符使用要求,也可以使用更複雜也更有彈性的 Predicate

舉例來說,如果要支援網址的分享,Info.plist 長得像這樣:

<key>NSExtensionAttributes</key>
<dict>
    <key>NSExtensionActivationRule</key>
    <dict>
        <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
        <integer>1</integer>
    </dict>
</dict>

有些 app 的分享功能同時分享了多種內容格式,例如瀏覽器的網頁分享可能同時包含「文字(這個網頁的標題)」跟「網址(這個網頁的網址)」。通常我們會希望分享內容有我們支援的格式,系統選單就要出現我們的 share extension,但實際情況是並不是如此。

以我們的例子來說,在 Info.plist 裡頭宣告支援網址格式,系統就會在分享內容「只有」網址格式的時候,才會出現我們的 share extension;不會在分享內容「同時有」網址跟其他格式的時候出現。

這顯然不是我們要的結果,解法就是在 Info.plist 加入 NSExtensionActivationDictionaryVersion key 並將 value 設為 2

<key>NSExtensionAttributes</key>
<dict>
    <key>NSExtensionActivationRule</key>
    <dict>
        <key>NSExtensionActivationDictionaryVersion</key>
        <integer>2</integer>
        <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
        <integer>1</integer>
    </dict>
</dict>

處理分享內容的時間點

網路上很多教學都是在 viewDidLoad 處理分享內容,但我測試發現會遇到一些奇怪的 UI 問題,所以我選擇在 viewDidAppear 處理。

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    // Make sure to load shared content and update UI after view did appear, otherwise UI may not work as expected.
    loadSharedContent()
}

private func loadSharedContent() {
    // ....
}

要在 Main Thread 處理 UI

這看起來很像是基本常識,但是在開發 Share Extension 的時候很容易忘記它。當你發現畫面不如預期變化的時候,很有可能就是沒有在 main thread 處理 UI 的關係。

private func loadSharedContent() {
    guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
          let attachment = item.attachments?.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.url.identifier) })
    else {
        finish()
        return
    }

    attachment.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] data, error in
        if let url = data as? URL {
            self?.handleURL(url)
        }
    }
}

private func finish() {
    DispatchQueue.main.async {
        self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
    }
}

private func handleURL(_ url: URL) {
    // Make sure to run in main thread, otherwise UI may freeze.
    DispatchQueue.main.async {
        self.label.text = url.absoluteString
    }
}

以上就是本次分享的內容,希望可以幫助各位少踩一些坑。