💯

SwiftUI での遅延コンテナ使用のヒントと注意点

2024/03/18に公開

SwiftUI のフレームワークでは、List や LazyVStack などの遅延レイアウトコンテナを使用して、大規模なデータセットを効率的に表示する方法が提供されています。これらのコンテナは巧妙に設計されており、必要になるまで動的にビューを構築しロードすることで、アプリケーションのパフォーマンスとメモリ使用効率を大幅に最適化します。この記事では、実用的なヒントと重要な注意点を探求し、開発者が SwiftUI の遅延コンテナを使用する際に、アプリケーションの応答性とリソース管理を強化するための能力を向上させることを目的としています。

この原文は私のブログ Fatbobman's Blog に掲載されています。Swift、SwiftUI、Core Data、SwiftData に関する最新のアップデートや優れた記事をお見逃しなく。Fatbobman's Swift Weekly に登録して、毎週の洞察と貴重なコンテンツを直接メールボックスにお届けします。

RandomAccessCollection を実装するカスタムクラス

場合によっては、データソースが SwiftUI の ForEach コンストラクタと直接互換性がないことがあります。ForEach はデータソースが効率的なランダムアクセスインデックス操作をサポートする RandomAccessCollection プロトコルに準拠している必要があるため、互換性のないデータソースに対してこのプロトコルに準拠したカスタムデータ型を定義する必要があり、これによりパフォーマンスとメモリ使用を最適化できます。

例として、Swift Collection ライブラリの OrderedDictionary を考えます。これは、効率的なキー値ストレージを備えた辞書と、順序付けられたコレクションの特性を組み合わせています。これを直接配列に変換すると、特に大規模なデータセットを扱う際に不要なメモリの増加を引き起こす可能性があります。RandomAccessCollection プロトコルに準拠したカスタムタイプを定義することで、メモリを効果的に管理しつつ、効率的なデータ処理能力を維持できます。

以下は、このようなカスタムコレクションの実装と使用の例です:

// DictDataSource を作成し、RandomAccessCollection の最小実装に準拠する
final class DictDataSource<Key, Value>: RandomAccessCollection, ObservableObject where Key: Hashable {
    typealias Index = Int
    
    private var dict: OrderedDictionary<Key, Value>
    
    init(dict: OrderedDictionary<Key, Value>) {
        self.dict = dict
    }
    
    var startIndex: Int {
        0
    }
    
    var endIndex: Int {
        dict.count
    }
    
    subscript(position: Int) -> (key: Key, value: Value) {
        dict.elements[position]
    }
}

// 使用例
let responses: OrderedDictionary<Int, String> = [
    200: "OK",
    403: "Access forbidden", 
    404: "File not found",
    500: "Internal server error",
]

struct OrderedCollectionsDemo: View {
    @StateObject var dataSource = DictDataSource(dict: responses)
    var body: some View {
        List {
            ForEach(dataSource, id: \.key) { element in
                Text("\(element.key) : \(element.value)")
            }
        }
    }
}

この方法を用いることで、大規模なデータセットの処理効率を最適化するだけでなく、メモリの使用量を減らし、アプリケーションの全体的な

応答性を向上させることができます。OrderedDictionary だけでなく、リンクリストやツリーなどの他のカスタムデータ構造も、同様の方法でランダムアクセスデータソースアダプターとして実装することができ、ForEach の使用とより良く連携できます。

無限データロードの実装

開発過程において、無限にデータをロードするシーンにしばしば遭遇します。これは、ユーザーのスクロールやニーズに応じて動的に新しいデータセットをロードすることを意味します。このメカニズムは、メモリ使用の最適化に役立つだけでなく、特に大量のデータを扱う際にユーザー体験を顕著に向上させることができます。

カスタムの RandomAccessCollection 実装を導入することで、データソースレベルで動的なロードロジックを直接組み込むことができます。以下に示す DynamicDataSource クラスは RandomAccessCollection プロトコルに準拠しており、データが末尾に近づいた時に自動的に追加データのロードをトリガーします。

struct Item: Identifiable, Sendable {
  let id = UUID()
  let number: Int
}

final class DynamicDataSource: RandomAccessCollection, ObservableObject {
  typealias Element = Item
  typealias Index = Int

  @Published private var items: [Item]
  private var isLoadingMoreData = false
  private let threshold = 10 // もっとデータをロードする閾値の設定

  init(initialItems: [Item] = []) {
    items = initialItems
  }

  var startIndex: Int {
    items.startIndex
  }

  var endIndex: Int {
    items.endIndex
  }

  func formIndex(after i: inout Int) {
    i += 1
    // 新しいデータをロードするトリガー条件
    if i >= (items.count - threshold) && !isLoadingMoreData && !items.isEmpty {
      loadMoreData()
    }
  }

  subscript(position: Int) -> Item {
    items[position]
  }

  private func loadMoreData() {
    guard !isLoadingMoreData else { return }

    isLoadingMoreData = true

    // 新しいデータを非同期にロードするシミュレーション
    Task {
      try? await Task.sleep(for: .seconds(1.5))
      let newItems = (0 ..< 30).map { _ in Item(number: Int.random(in: 0 ..< 1000)) }
      await MainActor.run { [weak self] in
        self?.items.append(contentsOf: newItems)
        self?.isLoadingMoreData = false
      }
    }
  }
}

ビューでの使用例は以下の通りです。ユーザーがスクロールするにつれて、新しいデータがリストに動的にロードされます:

struct DynamicDataLoader: View {
  @StateObject var dataSource = DynamicDataSource(initialItems: (0 ..< 50).map { _ in Item(number: Int.random(in: 0 ..< 1000)) })
  var body: some View {
    List {
      ForEach(dataSource) { item in
        Text("\(item.number)")
      }
    }
  }
}

random

上述の方法により、動的なデータロードのロジックとビューレンダリングのロジックを分離することに成功し、コードの構造をよりクリアでメンテナンスしやすいものにしました。この実装パターンは非常に幅広く適用可能で、データのモックアップに限らず、ネットワークからのデータロードやデータのページングロードなど、より多くの実際の要求に簡単に拡張することができます。

非同期コンパイル

の厳格なチェックが有効になっている場合、上記のデモコードはコンパイラ警告「Capture of 'self' with non-sendable type 'DynamicDataSource' in a @Sendable closure」を引き起こす可能性があります。これは、可能性のある並行実行のクロージャー内で selfDynamicDataSource タイプ)がキャプチャされており、DynamicDataSourceSendable プロトコルに準拠していないためです。@StateObject がインスタンスが常にメインスレッドで実行されることを保証し、すべてのインスタンスプロパティの変更がメインスレッドで行われることを考慮すると、この特定のシナリオでは @unchecked Sendable を使用して DynamicDataSource をマークし、警告を消去することは安全です。

id 修飾子が List の遅延読み込みメカニズムに与える影響

SwiftUI では、List ビューは遅延読み込みメカニズムに依存してパフォーマンスとユーザーエクスペリエンスを最適化します。この遅延読み込みメカニズムは、子ビューが視覚領域に表示される直前にならないと、SwiftUI がこれらのビューのインスタンスを作成してビューツリーにロードしないことを保証します。しかし、特定の状況下では、この最適化メカニズムが意図せず破壊される可能性があり、特に子ビューにid修飾子が使用されている場合に顕著です。

以下の例を考えてみましょう。ItemSubViewid修飾子を追加しました。その結果、List はすべてのアイテムに対して子ビューを即座にインスタンス化しますが、実際には現在表示されている子ビューの内容のみをレンダリングします(body が評価されます):

struct LazyBrokenView: View {
  @State var items = (0 ..< 50).map { Item(number: $0) }
  var body: some View {
    List {
      ForEach(items) { item in
        ItemSubView(item: item)
          .id(item.id)
      }
    }
  }
}

struct ItemSubView: View {
  let item: Item
  init(item: Item) {
    self.item = item
    print("\(item.number) init")
  }

  var body: some View {
    let _ = print("\(item.number) update")
    Text("\(item.number)")
      .frame(height: 30)
  }
}

id

List の遅延読み込みメカニズムは表示されているビューにのみ適用されますが、データ量が多い場合、すべての子ビューを即座にインスタンス化すると、大きなパフォーマンスコストがかかり、アプリの初期ロード効率に重大な影響を与える可能性があります。

ScrollViewReaderやビューを一意に識別する必要がある他の場合にidを使わざるを得ない場合は、遅延読み込み特性の損失を軽減するために他の方法を検討するべきです。より多くの最適化テクニックについては、SwiftUI List で大規模データセットを表示する際の応答効率の最適化 を参照してください。

id修飾子は List の遅延読み込み特性に影響を与えますが、LazyVStackLazyVGridなど他の遅延レイアウトコンテナでの使用は安全です。

さらに、WWDC2023 で導入されたScrollViewの新機能、例えばscrollPosition(id:)も、List 内でidと同様の効果を引き起こし、大規模なデータセットを扱う際にはこれらの特性を慎重に使用する必要があります。詳細は、SwiftUI 5 の ScrollView の新機能について深掘り下げる を参照してください。

要するに、SwiftUI の遅延コンテナの利点を最大限に活用する際には、これらの潜在的な落とし穴に注意を払う必要があり、特に大量のデータを表示する必要があるシナリオでは、アプリケーションのパフォーマンスとユーザーエクスペリエンスに深刻な影響を与える可能性があります。

遅延コンテナ内で、SwiftUI は ForEach の子ビューの最上位の状態のみを保持する

SwiftUI の遅延コンテナを使用する際、特に子ビューが多層のビュー構造で構成され、ForEachで構築されている場合に注意すべき重要な詳細があります。これらのビューが視覚的な領域から離れて再び表示される時、SwiftUI は最上位のビューの状態のみを保持します。

これは、以下のコード例で示されるように、ForEachループ内の子ビューとしてのRootViewが別のビューChildViewを含んでいる場合、RootViewが離れて再び可視領域に入ったとき、RootView内の状態のみが保持され、ChildViewにネストされた状態はリセットされることを意味します:

struct StateLoss: View {
  var body: some View {
    List {
      ForEach(0 ..< 100) { i in
        RootView(i: i)
      }
    }
  }
}

struct RootView: View {
  @State var topState = false
  let i: Int
  var body: some View {
    VStack {
      Text("\(i)")
      Toggle("Top State", isOn: $topState)
      ChildView()
    }
  }
}

struct ChildView: View {
  @State var childState = false
  var body: some View {
    VStack {
      Toggle("Child State", isOn: $childState)
    }
  }
}

state

当初はこの挙動がバグではないかと考えましたが、Apple のエンジニアとの交流を通じて、これは意図的な設計決定であることが明らかになりました。SwiftUI の設計哲学を考えると、この選択は理にかなっています。レンダリング効率とリソース消費のバランスを取るために、複雑なビュー構造と多数の子ビューを持つ遅延コンテナでは、すべての子ビューの状態を保持することが効率を大幅に低下させる可能性があります。したがって、最上位の子ビューの状態のみを維持することは、合理的な設計選択と言えます。

そのため、多層で状態豊富な子ビュー構造を持つ場合、すべての関連状態をトップレベルのビューに昇格させ、状態が正しく保持されるようにすることが推奨されます:

struct RootView: View {
  @State var topState = false
  @State var childState = false
  let i: Int
  var body: some View {
    VStack {
      Text("\(i)")
      Toggle("Top State", isOn: $topState)
      ChildView(childState: $childState)
    }
  }
}

struct ChildView: View {
  @Binding var childState: Bool
  var body: some View {
    VStack {
      Toggle("Child State", isOn: $childState)
    }
  }
}

state2

このアプローチは、SwiftUI の設計原則に従うだ

けでなく、複雑なビュー階層でもアプリケーションの状態が正しく管理され保持されることを保証します。

特定タイプの状態に対して、SwiftUI はメモリリソースを積極的に解放しない

Swift 開発者の一般的な認識として、オプショナル型の変数を nil に設定することで、システムが元のデータが占めていたリソースを回収するトリガーになると考えられています。したがって、重大なリソース消費を引き起こす可能性がある遅延ビューに対処する際、一部の開発者はこのメカニズムを利用してメモリを節約しようと試みます。以下の例は、このアプローチを示しています:

struct MemorySave: View {
  var body: some View {
    List {
      ForEach(0 ..< 100) { _ in
        ImageSubView()
      }
    }
  }
}

struct ImageSubView: View {
  @State var image: Image?
  var body: some View {
    VStack {
      if let image {
        image
          .resizable()
          .frame(width: 200, height: 200)
      } else {
        Rectangle().fill(.gray.gradient)
          .frame(width: 200, height: 200)
      }
    }
    .task {
      // 異なる画像をロードするシミュレーション
      if let uiImage = createImage() {
        image = Image(uiImage: uiImage)
      }
    }
    .onDisappear {
      // 視界から外れた時に nil を設定
      image = nil
    }
  }

  func createImage() -> UIImage? {
    let colors: [UIColor] = [.black, .blue, .yellow, .cyan, .green, .magenta]
    let color = colors.randomElement()!
    let size = CGSize(width: 1000, height: 1000)
    UIGraphicsBeginImageContextWithOptions(size, false, 0)
    color.setFill()
    UIRectFill(CGRect(origin: .zero, size: size))
    let image = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()
    return image
  }
}

memory

状態が nil に設定されても、遅延コンテナ内では、特定のタイプのデータ(例えば ImageUIImage など)が占有しているメモリスペースは、その遅延コンテナビューから完全に離れない限り、すぐには解放されません。この状況では、状態のタイプを変換することで、リソースの回収をより積極的に行うことができます:

struct ImageSubView: View {
  @State var data: Data?
  var body: some View {
    VStack {
      if let data, let uiImage = UIImage(data: data) {
        Image(uiImage: uiImage)
          .resizable()
          .frame(width: 200, height: 200)
      } else {
        Rectangle().fill(.gray.gradient)
          .frame(width: 200, height: 200)
      }
    }
    .task {
      // 異なる画像をロードし、Data タイプに変換するシミュレーション
      if let uiImage = await createImage() {
        data = uiImage
      }
    }
    .onDisappear {
      data = nil
    }
  }

  func createImage() async -> Data? {
    let colors: [UIColor] = [.black, .blue, .yellow, .cyan, .green, .magenta]
    let color = colors.randomElement()!
    let size = CGSize(width: 1000, height: 1000)
    UIGraphicsBeginImageContextWithOptions(size, false, 0)
    color.setFill()
    UIR

ectFill(CGRect(origin: .zero, size: size))
    let image = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()
    return image?.pngData()
  }
}

memory2

上記のコードでは、状態が保持する型を Optional<Data> に変更することで、状態を nil に設定した後、システムが占有しているリソースを正常に回収できるようになり、メモリ使用状況が改善されました。

詳細な分析と問題の探求については、SwiftUI + Core Data アプリのメモリ使用の最適化 を参照してください。

この方法により、メモリの使用を効果的に制御し、特に大量のデータやリソースが遅延ビューにロードされる場合に、アプリケーションのパフォーマンスと応答速度を保証することができます。

一部の読者は、この記事で紹介されている遅延コンテナが最上層の状態のみを保持する特性を利用してメモリ占有問題を解決することを考えるかもしれません。しかし残念ながら、これらの特定のタイプについては、状態が「忘れられた」としても、それらが占有するメモリはタイムリーに解放されないため、この方法は特定のタイプのメモリが積極的に解放されない問題を解決するのに適していません。

総括

SwiftUI は開発者に大きな利便性を提供し、動的で反応的なユーザーインターフェースの作成をよりシンプルにしますが、その遅延レイアウトコンテナの動作メカニズムを正しく理解し利用することは、効率的で性能の優れた SwiftUI アプリを開発する上での鍵です。本記事が開発者の皆さんがこれらのテクニックをより良く習得し、SwiftUI アプリを最適化する助けとなり、豊富な機能を提供しつつ、スムーズなユーザーエクスペリエンスと効率的なリソース使用を保証できることを願っています。

Discussion