[譯] iOS 中的 File Provider 拓展

在本教程中,你將學習 File Provider 拓展以及如何使用它把你 App 的內容公開出來。html

File Provider 在 iOS 11 中引入,它經過 iOS 的 文件 App 來訪問你 App 管理的內容。同時其餘的 App 也可使用 UIDocumentBrowserViewControllerUIDocumentPickerViewController 來訪問你 App 的數據。前端

File Provider 拓展的主要任務是:android

  • 建立表示雲端內容的佔位文件。
  • 當有 App 訪問文件內容時先對文件進行下載或上傳。
  • 在更新文件後發出通知來把更新上傳到服務器。
  • 枚舉存儲的文件和目錄。
  • 對文檔執行操做,例如重命名、移動或刪除。

你將使用 Heroku 按鈕 來配置託管文件的服務器。在服務器設置完成後,你須要配置擴展來對服務器的內容進行枚舉。ios

着手開始

首先,請先 下載資源,完成後找到 Favart-Starter 文件夾並打開 Favart.xcodeproj。確保你已選擇 Favart 的 scheme,而後編譯並運行該 App,你會看到如下內容:git

The container app for your File Provider.

該 App 提供了一個基礎的 View 來告訴用戶如何啓用 File Provider 擴展,由於你實際上不會在 App 內執行任何操做。每次在本教程中編譯運行 App 時,你都將返回主屏幕並打開 文件 這個 App 來訪問你的擴展。github

注意:若是要在真機上運行該項目,除了爲兩個 target 設置開發者信息外,還須要在 Configuration 文件夾中編輯 Favart.xcconfig。將 Bundle ID 更新爲惟一值。swift

示例項目將這個值用於兩個 target 中 build setting 裏的 PRODUCT_BUNDLE_IDENTIFIERProvider.entitlements 裏的 App Groups 標識符,還有 Info.plist 中的 NSExtensionFileProviderDocumentGroup。在項目中若是沒有同步更新它們,你將會獲得模糊而且讓人無法調試的編譯報錯信息,而使用自定義的 build settings 將會是一個聰明的方法。後端

示例項目中已經包含了你將用於 File Provider 擴展的基本組件:api

  • NetworkClient.swift 包含用於與 Heroku 服務器通訊的網絡請求客戶端。
  • FileProviderExtension.swift 就是 File Provider 拓展自己。
  • FileProviderEnumerator.swift 包含了枚舉器,用於枚舉目錄的內容。
  • Models 是一組用來完成擴展所需的模型。

使用 Heroku 設置後端

首先,你須要一個本身的後端服務器實例。幸運的是,使用 Heroku Button 將很容易完成這個操做。單擊下面的按鈕訪問 Heroku 的 dashboard。xcode

Deploy

在你註冊完 Heroku 的免費帳號後,你將看到如下頁面:

Deploy to Heroku

在此頁面上,你能夠給你的 App 取一個名字,也能夠將該字段留空,Heroku 將爲你自動生成一個名稱。沒必要配置其餘東西,如今你能夠點擊 Deploy app 按鈕,一下子以後你的後端就會啓動並運行。

Deploy successful

在 Heroku 完成部署 App 以後,單擊底部的 View。這會跳轉到你託管實例的後端 URL。在根目錄下,你應該看到一條 JSON 數據,是你熟悉的 Hello world!

最後,你須要複製 Heroku 實例的 URL,可是隻須要其中的域名部分:{app-name}.herokuapp.com

在 starter 項目中,打開 Provider/NetworkClient.swift。在文件的頂部,你應該會看到一條警告,告訴你 Add your Heroku URL here。刪除這個警告並用你的 URL 替換 components.host 佔位符字符串。

如今你就完成了服務器配置。接下來,你將定義 File Provider 所依賴的模型。

定義 NSFileProviderItem

首先,File Provider 須要一個遵循了 NSFileProviderItem 協議的模型。此模型將提供有關 File Provider 所管理的文件的信息。starter 項目在 FileProviderItem.swift 中已經定義了 FileProviderItem,在使用它以前須要遵循一些協議。

雖然該協議含有 27 個屬性,但咱們只須要其中 4 個。其餘一些可選屬性爲 File Provider 提供有關每一個文件的詳細信息以及一些其餘功能。在本教程中,你將用到以四個屬性:itemIdentifierparentItemIdentifierfilenametypeIdentifier

itemIdentifier 給模型提供了惟一標示符。File Provider 使用 parentIdentifier 來跟蹤它在擴展的層次結構中的位置。

filename文件 裏顯示的 App 名字。typeIdentifier 是一個 統一類型標識符(UTI)

FileProviderItem 能夠遵循 NSFileProviderItem 協議以前,它還須要一個處理來自後端數據的方法。MediaItem 定義了一個後端數據的簡單模型。咱們並非直接在 FileProviderItem 中使用這個模型,而是使用 MediaItemReference 來處理 File Provider 擴展的一些額外邏輯從而把其中的坑填上。

你將在本教程中使用 MediaItemReference 有兩個緣由:

  1. 在 Heroku 上託管的後端很是簡潔,它沒法提供 NSFileProviderItem 所需的全部信息,所以你須要在其餘地方獲取它。
  2. 這個 File Provider 擴展也很簡單,更完整的 File Provider 擴展須要使用諸如 Core Data 之類的東西在本地持久化存儲後端返回的數據,讓它能在該擴展的生命週期結束後引用它。

爲了將教程的重心放到 File Provider 擴展自己上,你將使用 MediaItemReference 來快速入門,你須要將四個必填字段嵌入到 URL 對象中。而後將該 URL 編碼成 NSFilProviderItemIdentifier。你不須要手動存儲其餘東西,由於 NSFileProviderExtension 會爲你處理它。

打開 Provider/MediaItemReference.swift 並把如下代碼添加到 MediaItemReference 裏:

// 1
private let urlRepresentation: URL

// 2
private var isRoot: Bool {
    return urlRepresentation.path == "/"
}

// 3
private init(urlRepresentation: URL) {
    self.urlRepresentation = urlRepresentation
}

// 4
init(path: String, filename: String) {
    let isDirectory = filename.components(separatedBy: ".").count == 1
    let pathComponents = path.components(separatedBy: "/").filter { !$0.isEmpty } + [filename]

    var absolutePath = "/" + pathComponents.joined(separator: "/")
    if isDirectory {
        absolutePath.append("/")
    }
    absolutePath = absolutePath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? absolutePath

    self.init(urlRepresentation: URL(string: "itemReference://\(absolutePath)")!)
}
複製代碼

如下是代碼的詳解:

  1. 在本教程中,URL 將包含 NSFileProviderItem 所需的大部分信息。
  2. 此計算屬性判斷當前項是不是文件系統的根目錄。
  3. 你將此初始化方法設爲私有以防止從模型外部調用。
  4. 從後端讀取數據時,你將調用此初始化方法。若是文件名不包含文件後綴,則它必定是個文件夾,由於初始化方法並不能自動推斷其類型。

在添加最終初的始化器以前,請把文件頂部的 import 語句替換成:

import FileProvider
複製代碼

接下來在剛剛那段代碼下面添加如下初始化器:

init?(itemIdentifier: NSFileProviderItemIdentifier) {
    guard itemIdentifier != .rootContainer else {
        self.init(urlRepresentation: URL(string: "itemReference:///")!)
        return
    }

    guard let data = Data(base64Encoded: itemIdentifier.rawValue),
        let url = URL(dataRepresentation: data, relativeTo: nil)
    else {
        return nil
    }

    self.init(urlRepresentation: url)
}
複製代碼

大部分擴展都將使用此初始化器。注意開頭的 itemReference://。你能夠單獨處理根目錄的標識符以確保能正確設置其 URL 的路徑。

對於其餘項,你能夠將標識符的原始值轉換爲 base64 編碼後的數據來檢索 URL。URL 中的信息來自第一次對實例進行枚舉的網絡請求。

既然如今初始化器已經設置好了,是時候爲這個模型添加一些屬性了。首先,在文件頂部添加以下 import

import MobileCoreServices
複製代碼

這將讓你能夠訪問文件類型,在這個結構體裏繼續添加:

// 1
var itemIdentifier: NSFileProviderItemIdentifier {
    if isRoot {
        return .rootContainer
    } else {
        return NSFileProviderItemIdentifier(rawValue: urlRepresentation.dataRepresentation.base64EncodedString())
    }
}

var isDirectory: Bool {
    return urlRepresentation.hasDirectoryPath
}

var path: String {
    return urlRepresentation.path
}

var containingDirectory: String {
    return urlRepresentation.deletingLastPathComponent().path
}

var filename: String {
    return urlRepresentation.lastPathComponent
}

// 2
var typeIdentifier: String {
    guard !isDirectory else {
        return kUTTypeFolder as String
    }

    let pathExtension = urlRepresentation.pathExtension
    let unmanaged = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, pathExtension as CFString, nil)
    let retained = unmanaged?.takeRetainedValue()

    return (retained as String?) ?? ""
}

// 3
var parentReference: MediaItemReference? {
    guard !isRoot else {
        return nil
    }
    return MediaItemReference(urlRepresentation: urlRepresentation.deletingLastPathComponent())
}
複製代碼

你須要知道記住如下幾點:

  1. 對於 FileProvider 管理的每一項,itemIdentifier 必須是惟一的。若是是根目錄,那麼它使用 NSFileProviderItemIdentifier.rootContainer,不然從 URL 建立一個標識符。
  2. 這裏它根據拓展路徑的 URL 建立一個標識符,看上去很奇怪的 UTTypeCreatePreferredIdentifierForTag 其實是一個返回給定輸入的 UTI 類型的 C 函數。
  3. 在處理目錄型結構時,對於父級的引用很是有用。這個屬性表示了包含當前引用的文件夾。它是一個可選類型,由於根目錄是沒有父級的。

你在此處添加了一些其餘屬性,這些屬性不須要太多解釋,但在建立 NSFileProviderItem 時很是有用。如今參考模型已經建立完成了,是時候把全部東西與 FileProviderItem 進行掛鉤了。

打開 FileProviderItem.swift 並在頂部添加:

import FileProvider
複製代碼

而後在文件的最底部添加:

// MARK: - NSFileProviderItem

extension FileProviderItem: NSFileProviderItem {
    // 1
    var itemIdentifier: NSFileProviderItemIdentifier {
        return reference.itemIdentifier
    }

    var parentItemIdentifier: NSFileProviderItemIdentifier {
        return reference.parentReference?.itemIdentifier ?? itemIdentifier
    }

    var filename: String {
        return reference.filename
    }

    var typeIdentifier: String {
        return reference.typeIdentifier
    }

    // 2
    var capabilities: NSFileProviderItemCapabilities {
        if reference.isDirectory {
            return [.allowsReading, .allowsContentEnumerating]
        } else {
            return [.allowsReading]
        }
    }

    // 3
    var documentSize: NSNumber? {
        return nil
    }
}
複製代碼

FileProviderItem 如今已經遵循 NSFileProviderItem 並實現了全部必須的屬性。以上代碼的詳解以下:

  1. 大多數必須的屬性映射了你以前添加到 MediaItemReference 的邏輯。
  2. NSFileProviderItemCapabilities 表示能夠對文檔瀏覽器中的項目執行哪些操做,例如讀取和刪除。對於該 App,你只須要容許讀取和枚舉目錄。在實際項目中,你可能會使用 .allowsAll,由於用戶但願全部操做均可以進行。
  3. 本教程不會用到文檔的大小,把它包含在裏面以防止 NSFileProviderManager.writePlaceholder(at:withMetadata:) 會崩潰。這多是框架的一個錯誤,可是通常狀況下 App 的文件擴展不管如何都會提供 documentSize

以上就是模型,NSFileProviderItem 還有更多其餘屬性,可是你目前實現的已經足夠了。

枚舉文件

如今模型已經完善好了,能夠拿來使用了。你須要告訴系統你 App 裏有什麼內容才能向用戶展現模型定義的 item。

NSFileProviderEnumerator 定義系統和 App 內容間的關係。你稍後將看到系統是如何經過提供表示當前上下文的 NSFileProviderItemIdentifier 從而請求枚舉器的。若是用戶當前在根目錄下,系統將會提供 .rootContainer 標識符。在其餘目錄下時,系統則會傳入你模型定義的項目的標識符。

首先,在 starter 裏構建枚舉器。打開 Provider/FileProviderEnumerator.swift 並在 path 下添加:

private var currentTask: URLSessionTask?
複製代碼

此屬性將存儲對當前網絡請求任務的引用。這提可讓你隨時取消請求。

接下來把 enumerateItems(for:startingAt:) 裏的內容替換成:

let task = NetworkClient.shared.getMediaItems(atPath: path) { results, error in
    guard let results = results else {
        let error = error ?? FileProviderError.noContentFromServer
        observer.finishEnumeratingWithError(error)
        return
    }

    let items = results.map { mediaItem -> FileProviderItem in
        let ref = MediaItemReference(path: self.path, filename: mediaItem.name)
        return FileProviderItem(reference: ref)
    }

    observer.didEnumerate(items)
    observer.finishEnumerating(upTo: nil)
}

currentTask = task
複製代碼

這裏實現了 NetworkClient 單例獲取指定路徑的內容。請求成功後,枚舉器的觀察者經過調用 didEnumeratefinishEnumerating(upTo:) 來返回新的數據。經過 finishEnumeratingWithError 來通知枚舉器的觀察者請求到的結果是否有錯誤。

注意:實際的 App 可能使用分頁來獲取數據,這就會用到 NSFileProviderPage 來執行此操做。首先 App 將使用整數做爲頁面索引,而後將其序列化並存儲在 NSFileProviderPage 結構體中。

最後你講把下面的內容添加到 invalidate() 來完成這個枚舉器:

currentTask?.cancel()
currentTask = nil
複製代碼

若是有須要,那就會取消當前的網絡請求,由於有些狀況下可能須要訪問用戶的網絡狀態或者當前的位置,也多是一些一些資源的使用狀況。

完成該方法後,你就可使用此枚舉器訪問後端服務器的數據,接下來就會進入 FileProviderExtension 類。

打開 Provider/FileProviderExtension.swift 並把 item(for:) 的代碼替換成:

guard let reference = MediaItemReference(itemIdentifier: identifier) else {
    throw NSError.fileProviderErrorForNonExistentItem(withIdentifier: identifier)
}
return FileProviderItem(reference: reference)
複製代碼

系統會提供 identifier 參數,而且你須要給那個 identifier 返回一個 FileProviderItem。這個 guard 語句確保了建立的 MediaItemReference 是有效的。

接下來,把 urlForItem(withPersistentIdentifier:)persistentIdentifierForItem(at:) 替換成如下內容:

// 1
override func urlForItem(withPersistentIdentifier identifier: NSFileProviderItemIdentifier) -> URL? {
    guard let item = try? item(for: identifier) else {
        return nil
    }

    return NSFileProviderManager.default.documentStorageURL
      .appendingPathComponent(identifier.rawValue, isDirectory: true)
      .appendingPathComponent(item.filename)
}

// 2
override func persistentIdentifierForItem(at url: URL) -> NSFileProviderItemIdentifier? {
    let identifier = url.deletingLastPathComponent().lastPathComponent
    return NSFileProviderItemIdentifier(identifier)
}
複製代碼

如下是代碼詳解:

  1. 驗證一下來確保給定的 identifier 能解析爲擴展模型的實例。而後返回一個文件 URL,它是將項目存儲在文件管理器裏的位置。
  2. urlForItem(withPersistentIdentifier:) 返回的每一個 URL 都須要映射回最初設置的 NSFileProviderItemIdentifier。在該方法中,你要以 <documentStorageURL>/<itemIdentifier>/<filename> 的格式構建 URL 並採用 <itemIdentifier> 做爲標識符。

如今有兩個方法都須要你傳入一個指向遠端文件的佔位符 URL 。首先你將建立一個幫助輔助方法來完成這個功能,將如下內容添加到 providePlaceholder(at:)

// 1
guard let identifier = persistentIdentifierForItem(at: url),
    let reference = MediaItemReference(itemIdentifier: identifier)
else {
    throw FileProviderError.unableToFindMetadataForPlaceholder
}

// 2
try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)

// 3
let placeholderURL = NSFileProviderManager.placeholderURL(for: url)
let item = FileProviderItem(reference: reference)

// 4
try NSFileProviderManager.writePlaceholder(at: placeholderURL, withMetadata: item)
複製代碼

以上代碼完成的功能以下:

  1. 首先你從提供的 URL 建立 identifier 和 reference。若是失敗了則拋出錯誤。
  2. 建立佔位符時,必須確保這個目錄是存在的,不然就會遇到問題,使用 NSFileManager 來執行此操做。
  3. 這個 url 參數是用來顯示圖像的,而不是佔位符。所以你要使用 placeholderURL(for:) 來建立佔位符 URL,並獲取此佔位符將表示的 NSFileProviderItem
  4. 將佔位符寫入文件系統。

接下來把 providePlaceholder(at:completionHandler:) 的內容替換成:

do {
    try providePlaceholder(at: url)
    completionHandler(nil)
} catch {
    completionHandler(error)
}
複製代碼

當 File Provider 須要一個佔位符 URL 時,它將調用 providePlaceholder(at:completionHandler:)。你將嘗試使用上面的輔助方法建立佔位符,若是拋出錯誤,則將其傳遞給 completionHandler。就像在 providePlaceholder(at:) 中同樣,這個步驟成功以後就不須要傳遞任何內容,File Provider 只須要你的佔位符 URL。

當用戶在目錄下切換時,File Provider 將調用 enumerator(for:) 來請求給定 identifier 的 FileProviderEnumerator。用如下內容替換該法的內容:

if containerItemIdentifier == .rootContainer {
    return FileProviderEnumerator(path: "/")
}

guard let ref = MediaItemReference(itemIdentifier: containerItemIdentifier), ref.isDirectory
else {
    throw FileProviderError.notAContainer
}

return FileProviderEnumerator(path: ref.path)
複製代碼

此方法確保了給定 identifier 對應的是一個目錄。若是是根目錄,則仍然建立枚舉器,由於根目錄也是有效目錄。

編譯並運行,App 啓動後,打開 文件 App,點擊兩次右下角的 瀏覽,你就會進入 文件 的根目錄。選擇 更多位置,會出現 提供者 或展開一個列表,點擊開啓你 App 的拓展。

注意:若是找不到 更多位置 展開的項目不能點擊,你能夠再點擊一下右上角的 編輯 按鈕。

First look at the extension.

你如今有一個有效的 File Provider 擴展了,可是還缺乏一些重要的東西,接下來你將添加它們。

提供縮略圖

由於 App 會顯示後端請求來的圖片,所以顯示出圖像的縮略圖很是重要,你能夠重寫一個方法來生成縮略圖。

enumerator(for:) 下面添加:

// MARK: - Thumbnails

override func fetchThumbnails(for itemIdentifiers: [NSFileProviderItemIdentifier], requestedSize size: CGSize, perThumbnailCompletionHandler: @escaping (NSFileProviderItemIdentifier, Data?, Error?) -> Void,
                              completionHandler: @escaping (Error?) -> Void) -> Progress {
    // 1
    let progress = Progress(totalUnitCount: Int64(itemIdentifiers.count))

    for itemIdentifier in itemIdentifiers {
        // 2
        let itemCompletion: (Data?, Error?) -> Void = { data, error in
            perThumbnailCompletionHandler(itemIdentifier, data, error)

            if progress.isFinished {
                DispatchQueue.main.async {
                    completionHandler(nil)
                }
            }
        }

        guard let reference = MediaItemReference(itemIdentifier: itemIdentifier), !reference.isDirectory
        else {
            progress.completedUnitCount += 1

            let error = NSError.fileProviderErrorForNonExistentItem(withIdentifier: itemIdentifier)
            itemCompletion(nil, error)
            continue
        }

        let name = reference.filename
        let path = reference.containingDirectory

        // 3
        let task = NetworkClient.shared.downloadMediaItem(named: name, at: path) { url, error in
            guard let url = url, let data = try? Data(contentsOf: url, options: .alwaysMapped) else {
                itemCompletion(nil, error)
                return
            }
            itemCompletion(data, nil)
        }

        // 4
        progress.addChild(task.progress, withPendingUnitCount: 1)
    }

    return progress
}
複製代碼

雖然這種方法很是冗長,但其邏輯很簡單:

  1. 此方法返回一個 Progress 對象,該對象會記錄每一個縮略圖請求的狀態。
  2. 它爲每一個 itemIdentifier 定義了一個 completion 閉包,該閉包將負責調用此方法所需的每一個項的閉包以及最後調用最後一個閉包。
  3. 使用 starter 項目附帶的 NetworkClient 將縮略圖文件從服務器下載到臨時文件。在下載完成後,completion handler 將下載的 data 傳遞給 itemCompletion 閉包。
  4. 每一個下載任務都做爲依賴項添加到父進程對象。

注意:在處理較大的數據時,爲每一個佔位符都發出單獨的網絡請求可能須要耗費一些時間。所以若是可能的話,你的後端應提供單個請求中的批量下載圖像方法。

編譯並運行。打開 文件 裏的拓展就能看到你的縮略圖了:

The thumbnails are now working.

顯示完整圖片

如今當你選擇一個項目時,該 App 將會顯示一個沒有完整圖像的空白視圖:

No content.

到目前爲止,你只實現了預覽縮略圖的顯示,還須要添加完整圖片的顯示。

與縮略圖生成同樣,讓完整的圖片顯示只須要一個方法,即 startProvidingItem(at:completionHandler:)。將如下內容添加到 FileProviderExtension 類的底部:

// MARK: - Providing Items

override func startProvidingItem(at url: URL, completionHandler: @escaping ((_ error: Error?) -> Void)) {
    // 1
    guard !fileManager.fileExists(atPath: url.path) else {
        completionHandler(nil)
        return
    }

    // 2
    guard let identifier = persistentIdentifierForItem(at: url), let reference = MediaItemReference(itemIdentifier: identifier) else {
        completionHandler(FileProviderError.unableToFindMetadataForItem)
        return
    }

    // 3
    let name = reference.filename
    let path = reference.containingDirectory
    NetworkClient.shared.downloadMediaItem(named: name, at: path, isPreview: false) { fileURL, error in
        // 4
        guard let fileURL = fileURL else {
            completionHandler(error)
            return
        }

        // 5
        do {
            try self.fileManager.moveItem(at: fileURL, to: url)
            completionHandler(nil)
        } catch {
            completionHandler(error)
        }
    }
}
複製代碼

以上的代碼功能是:

  1. 檢查指定 URL 中是否已存在某項,防止再次請求相同的數據。在實際項目中,你應該檢查修改日期和文件版本號,確保你得到的是最新數據。可是,在本教程中沒有必要這樣作,由於它並不支持版本控制。
  2. 獲取相關 URLMediaItemReference 來確認須要從後端請求哪一個文件。
  3. 從 reference 中提取文件名稱和路徑,而後進行請求。
  4. 若是下載文件時出錯,則把錯誤傳給錯誤處理閉包。
  5. 將文件從其臨時下載目錄移動到擴展名指定的文檔存儲 URL。

編譯並運行,打開擴展後選擇任何一張圖,你能夠看到完整的圖片。

A full image is loaded.

當你打開更多文件時,該擴展程序須要刪除已經下載了的文件,File Provider 擴展內置了這個功能。

你必須重寫 stopProvidingItem(at:),這樣才能清理下載了的文件並提供新的佔位符。在 FileProviderExtension 類的底部添加如下內容:

override func stopProvidingItem(at url: URL) {
    try? fileManager.removeItem(at: url)
    try? providePlaceholder(at: url)
}
複製代碼

這樣就能刪除圖片,並調用 providePlaceholder(at:) 來生成一個新的佔位符。

以上就完成了 File Provider 的最基本功能。文件枚舉,縮略圖預覽以及查看文件內容是此擴展的基本組件。

到如今爲止,你的 File Provider 的功能就齊全了。

接下來該幹嗎?

你如今已經擁有了一個包含了有效的 File Provider 的 App,這個擴展程序能夠枚舉以及顯示後端服務器的東西。

你能夠點擊 下載資源 來下載完整版的項目。

你能夠在 Apple 關於 File Provider 的文檔 中瞭解更多有關 File Provider 的操做。你還可使用其餘擴展程序將自定義 UI 添加到 File Provider,你能夠從 這裏 能夠閱讀到更多相關信息。

若是你對其餘在 iOS 上使用文件的操做感興趣,你能夠查看 的更多方式感興趣,請查看 基於文檔的 App

但願你喜歡這個教程!若是你有任何問題或意見,能夠加入 原文 最下面的討論組。

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索