👻

Transferable入門

2024/12/21に公開

Transferableは、共有やデータ転送のためのプロトコルです。これを用いてモデルをシリアライズおよびデシリアライズする方法を宣言的に記述することができます。モデルを拡張して文字列や画像として他のアプリと共有したり、カスタム宣言したデータタイプを作成し共有することもできます。

WWDC22(iOS 16, etc.)での初登場時はドラッグ&ドロップやコピー&ペーストなどをサポートする文脈で紹介されましたが、WWDC24ではApp Intents/Apple Intelligenceの文脈でもこのTransferableが登場しました [1]

本記事ではこのTransferableの入門として、WWDC22の"Meet Transferable"というセッションの内容をまとめます。同セッションではTransferableについて以下のような内容が解説されています。

  • 一般的なユースケースにおけるAPIの使用方法
  • 高度な機能を活用した動作のカスタマイズ方法
  • 大量のデータを処理する場合に、メモリ効率を最適化する方法

以下、文章、画像はWWDCの基本的に同セッションからの引用です。適宜要約したり、順序を並び替えたり、見出しを追加したりしています。

作るアプリの説明

ドラッグ&ドロップ、コピー&ペースト、その他のアプリの機能をサポートする宣言的方法について説明します。

SwiftUIやMacのアプリ開発とは別にコンピュータサイエンスにおける女性技術者に興味がありました。ヒーローを知ることは重要ですよね。女性技術者を表示・追加・編集できるカタログアプリを作りました。

3

エンジニアや科学者のプロフィールです。

このアプリはアプリ間でシームレスに

5

科学者のプロフィールや知識の移動やドラッグ&ドロップをサポートすることを目指します。

9

また、初めてアプリでwatchOSとの共有をサポートします。

ウォッチからパーソナリティ・プロフィールを共有したいとの要望がありました。

SwiftUIによってiOSとMacでも共有できます。

13

今年ShareSheetのデザインも一新されました。

すべてを可能にするにはアプリ内や他のアプリケーションのレシーバーへの送信をサポートするモデルが必要です。

16

  • プロフィールのstructには一人の人格に関するすべての情報が含まれています。
  • すべてのプロフィールをアーカイブして友達と共有できます。
  • 技術者の情報を文字で保存し、
  • ビデオも添付できます。

これらのモデル・タイプをすべて共有する新しい容易な方法があります。Transferableを紹介します!

これは共有やデータ転送のためにモデルをシリアライズおよびデシリアライズする方法を記述するための、Swift初の宣言的な方法です。

アジェンダ

  • Anatomy of Transferable
    • Transferableとは何か、それを使うときに裏で何が起こっているのか
  • Conforming custom types
    • カスタムタイプへの適合方法
  • Advanced tips and tricks
    • Transferableをカスタマイズして必要なことを行うのに役立つ高度なヒントやトリック

Anatomy of Transferable

送信するバイナリデータが何なのかを判断する

2つのアプリが起動しており、ユーザーがコピー&ペースト・ShareSheet・ドラッグまたは他のアプリの機能を使って、あるアプリから別のアプリに情報を渡したい場合、2つの異なるアプリ間で何かを送信するとき、このすべてのバイナリデータが行き交います。

26

このデータを送信する際に重要なのは、そのデータが何に対応しているかを判断することです。

テキストやビデオや、ある女性技術者のプロフィール、あるいは全体のアーカイブかもしれません。

31

Uniform type identifiers

データが何なのか説明するUTTypeもあります。

アプリのバイナリデータの生成を見てみましょう。

他のアプリあるいは同じアプリ内で共有される情報をバイナリデータに、あるいはその逆に相互変換する方法と

37

バイナリデータの構造を表すコンテントタイプが必要です。

コンテントのタイプ–uniform type identifiersとは、異なるバイナリーの構造と抽象概念の識別子について記述するアップルの独自技術です。識別子はツリーで

40

カスタム識別子も定義できます。

例えば、プロフィールのバイナリデータ構造のカスタム識別子の宣言に使います。

宣言をInfo.plistファイルに加え、ファイル拡張子も加えます。

45

データがディスク上にある場合、システムはこれをあなたのアプリが開ける事をこの情報から知ります。

次にコードで宣言します。

import UniformTypeIdentifiers

// also declare the content type in the Info.plist
extension UTType {
    static var profile: UTType = UTType(exportedAs: "com.example.profile")
}

コンテンツのタイプの学習には、このビデオをすすめます。

https://developer.apple.com/jp/videos/play/tech-talks/10696/

Uniform Type Identifiersとは何で、どう使うのか明快に説明しています。

Standard Transferable types

良いニュースは多くの標準タイプが

51

すでにTransferableに準拠しています。例えば、文字列・データ・URL、書式付き文字列・画像などです。

PasteButton

新しいSwiftUIのペーストボタンを使う数行のコードで、

53

プロフィールにペーストしたり、

コード全体
import SwiftUI

struct Profile {
    private var funFacts: [String] = []
    mutating func addFunFacts(_ newFunFacts: [String]) {
        funFacts.append(newFunFacts)
    }
}

struct PasteButtonView: View {
    @State var profile = Profile()
    var body: some View {
        PasteButton(payloadType: String.self) { funFacts in
            profile.addFunFacts(funFacts)
        }
    }
}

ビューから画像をドラッグしたり、

Finderや他のアプリからの画像のドロップをサポートします。

56

コード全体
import SwiftUI

struct PortraitView: View {
    @State var portrait: Image
    var body: some View {
        portrait
            .cornerRadius(8)
            .draggable(portrait)
            .dropDestination(payloadType: Image.self) { (images: [Image], _) in
                if let image = images.first {
                    portrait = image
                    return true
                }
                return false
            }
    }
}

新しいShareLinkで、

57

ウォッチから共有を実装できます。

コード全体
import SwiftUI

struct Profile {
    var name: String
}

struct ProfileView: View {
    @State private var portrait: Image
    var model: Profile

    var body: some View {
        VStack {
            portrait
            Text(model.name)
        }
        .toolbar {
            ShareLink(item: portrait, preview: SharePreview(model.name))
        }
    }
}

Conforming custom types

Transferableとは何かとその使い方の基本を説明しました。

アプリのモデルにTransferable準拠を追加する方法を見てみましょう。

言及したように今回のアプリでは、

59

4つのモデルタイプを共有します。

文字列は既に対応しています。何も必要ありません。

1つのプロフィール、プロファイルのアーカイブ、およびビデオの共有ではどうでしょう。

Transferableに準拠させるために実装するのはただ一つ。

TransferRepresentationだけです。

extension Profile: Transferable {
    static var transferRepresentation: some TransferRepresentation {

    }
}

Transfer representations

モデルがどのように転送されるか記述します。

3つ重要なRepresentationがあります。

  • CodableRepresentation
  • DataRepresentation
  • FileRepresentation

それぞれ説明します。

最初は中心となるモデルのProfileの構造です。

id・名前・略歴・趣味・ポートレイト・ビデオです。

import Foundation

struct Profile: Codable {
    var id: UUID
    var name: String
    var bio: String
    var funFacts: [String]
    var video: URL?
    var portrait: URL?
}

CodableRepresentation

既にCodableに準拠しますので、TransferableにはCodableRepresentationを含めることができます。

68

Codableはエンコーダでプロフィールをバイナリデータへ変換し、デコーダが解読します。デフォルトでJSONを使用しますが、自分のエンコーダとデコーダも使用できます。

Codableプロトコルの詳細と、エンコーダとデコーダ仕組みは、

このプロトコルが最初に登場したWWDC18の「データを信頼できるものに」をご覧ください。

プロフィールに戻ると、Codableで必要な情報は希望のコンテンツのタイプのみです。

extension Profile: Codable, Transferable {
    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(contentType:       )
    }
}

カスタム・フォーマットになるので、カスタムと宣言されたuniform type identifierを使います。

プロフィールとコンテンツタイプを加え完了です。

extension Profile: Codable, Transferable {
    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(contentType: .profile)
    }
}

// also declare the content type in the Info.plist
extension UTType {
    static var profile: UTType = UTType(exportedAs: "com.example.profile")
}

プロフィールはTransferableに準拠しています!

コード全体
import CoreTransferable
import UniformTypeIdentifiers

struct Profile: Codable {
    var id: UUID
    var name: String
    var bio: String
    var funFacts: [String]
    var video: URL?
    var portrait: URL?
}

extension Profile: Codable, Transferable {
    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(contentType: .profile)
    }
}

// also declare the content type in the Info.plist
extension UTType {
    static var profile: UTType = UTType(exportedAs: "com.example.profile")
}

DataRepresentation

別のケースのProfilesArchiveです。

74

既にCSVデータ変換がサポートされていますので、リストをCSVファイルでエクスポートし、友達とのシェアしたり、別のマシンにインポートできます。

これ(convertToCSV())でArchiveはDataに相互変換できますので、DataRepresentationに準拠したことになります。

中を覗いてみると、DataRepresentationは変換関数を使って直接バイナリ表現を作り、

79

レシーバーのために値を再構築しています。

extension ProfilesArchive: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        DataRepresentation(contentType: .commaSeparatedText) { archive in
            try archive.convertToCSV()
        } importing: { data in
            try ProfilesArchive(csvData: data)
        }
    }
}

DataRepresentationの使用でTransferable対応は容易です。2つの既にある関数initializer、またCSVコンバータを呼び出し利用するだけです。

コード全体
import CoreTransferable
import UniformTypeIdentifiers

struct ProfilesArchive {
    init(csvData: Data) throws { }
    func convertToCSV() throws -> Data { Data() }
}

extension ProfilesArchive: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        DataRepresentation(contentType: .commaSeparatedText) { archive in
            try archive.convertToCSV()
        } importing: { data in
            try ProfilesArchive(csvData: data)
        }
    }
}

FileRepresentation

プロフィールにビデオがある場合、ドラッグと共有もしたいですね。しかしビデオは大きくなります。メモリーにはロードしたくないですね。そこでFileRepresentationです。

再び中身を見てみると、

83

FileRepresentationは提供されるURLをレシーバーに送信し、それを使ってTransferableアイテムを再構築しています。

FileRepresentationは、

85

ディスク・ファイルに書かれたバイナリー表現で共有を可能にします。

要約しますと、単純なユースケースで、単一の表現だけの場合、

87

まずそのモデルがCodableに準拠していて、特定のバイナリ形式が必要か確認します。

必要であればCodableRepresentationを使用します。

そうでなければメモリーかディスクに保存可能かチェックします。

メモリーならDataRepresentation、ディスクならFileRepresentationです。

Advanced tips and tricks

Transferableはシンプルな使用だけでなく、複雑なものにも対応し、ほとんどの場合わずか数行です。見てください!

ProxyRepresentation

ProfileTransferableに準拠していますが、さらにProfile`がコピーされ、テキスト欄にペーストされる場合、プロフィールの名前を貼りたいですよね。

もう一つの表現を加える必要があります。

ProxyRepresentationは他のTransferableで我々のモデルを表現することを可能にします。

93

Profileは1行のテキストで貼りつけられます。

ProxyRepresentationCodableの後に追加しました。順番は重要です。

レシーバーは対応するコンテンツタイプの最初の表現を使います。

レシーバーがカスタムコンテンツのプロフィールタイプを知っている場合は、これを使用します。

テキストをサポートしていない場合は、代わりにProxyRepresentationを使用します。

import CoreTransferable
import UniformTypeIdentifiers

struct Profile: Codable {
    var id: UUID
    var name: String
    var bio: String
    var funFacts: [String]
    var video: URL?
    var portrait: URL?
}

extension Profile: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(contentType: .profile)
        ProxyRepresentation(exporting: \.name)
    }
}

// also declare the content type in the Info.plist
extension UTType {
    static var profile: UTType = UTType(exportedAs: "com.example.profile")
}

これでProfileはエンコーダとデコーダ両方の変換と、

105

テキスト変換をサポートします。

ProxyRepresentationはこの場合、テキストへのエクスポートのみを記述し、プロフィールを再構築することはしません。

どんな表現も、両方の変換か、あるいは1つだけを記述できます。

Proxy and file representations

ProxyRepresentationsをご説明しましたが、ビデオにFileRepresentationは必要でしょうか?

URLでプロキシを利用できます。違いはわずかです。

FileRepresentation

106

ディスクに書かれたURLを意図しており、一時的なサンドボックス拡張を付与することで、このファイルあるいはそのコピーへのレシーバーのアクセスを保証します。

107

ProxyRepresentationはURLを同じように扱います。

ファイルに必要な追加機能はありません。つまり両方を持つことができます。

最初はFileRepresentationで、レシーバーがこのコンテンツの映画のファイルにアクセスできるようにします。

2番目のものはコピーしたビデオをテキストフィールドにペーストすると動作します。

import CoreTransferable

struct Video: Transferable {
    let file: URL
    static var transferRepresentation: some TransferRepresentation {
        FileRepresentation(contentType: .mpeg4Movie) { SentTransferredFile($0.file) }
           importing: { received in
               let copy = try Self.copyVideoFile(source: received.file)
               return Self.init(file: copy) }
        ProxyRepresentation(exporting: \.file)
  }

    static func copyVideoFile(source: URL) throws -> URL {
        let moviesDirectory = try FileManager.default.url(
            for: .moviesDirectory, in: .userDomainMask,
            appropriateFor: nil, create: true
        )
        var destination = moviesDirectory.appendingPathComponent(
            source.lastPathComponent, isDirectory: false)
        if FileManager.default.fileExists(atPath: destination.path) {
            let pathExtension = destination.pathExtension
            var fileName = destination.deletingPathExtension().lastPathComponent
            fileName += "_\(UUID().uuidString)"
            destination = destination
                .deletingLastPathComponent()
                .appendingPathComponent(fileName)
                .appendingPathExtension(pathExtension)
        }
        try FileManager.default.copyItem(at: source, to: destination)
        return destination
    }
}

つまりファイルとプロキシの表現で、URLの扱いが全く異なるのです。

111

最初のケースではペイロードはディスクのアセットです。

二番目のケースのペイロードは、リモートのWebのURL structです。

Exporting condition

もう一つバージョンアップしたいのがProfileArchiveのモデルです。CSVへの変換に対応していない場合があるので、

extension ProfilesArchive: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        DataRepresentation(contentType: .commaSeparatedText) { archive in
            try archive.convertToCSV()
        } importing: { data in
            try ProfilesArchive(csvData: data)
        }
    }
}

コードに反映させたいと思います。

見てみましょう。

114

CSVにエクスポートできるか、データ間で変換機能があるかBooleanのプロパティを加えます。.exportingConditionを使います。

115

CSVをサポートしないアーカイブでは、このフォーマットでエクスポートできません。

コード全体
import CoreTransferable
import UniformTypeIdentifiers

struct ProfilesArchive {
    var supportsCSV: Bool { true }
    init(csvData: Data) throws {  }
    func convertToCSV() throws -> Data { Data() }
}

extension ProfilesArchive: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        DataRepresentation(contentType: .commaSeparatedText) { archive in
            try archive.convertToCSV()
        } importing: { data in
            try Self(csvData: data)
        }
        .exportingCondition { $0.supportsCSV }
    }
}

このAPIを使えばSwiftUIのカスタムViewのように、カスタムのTransferRepresentationを構築することが可能です。

唯一の要件は、他の表現を必要な方法で構成することができるbodyプロパティを提供することです。

複数の表現を組み合わせて、

116

再利用したい場合や、公に公開したくないプライベートなデータ表現がある場合などに便利です。

Transferableのおかげで、

118

私が望んでいた機能をすべて備えたこのアプリを迅速に構築することができました。

脚注
  1. こちらについてはまた別記事で解説します。 ↩︎

Discussion