0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【開発日誌 Day7】Historyの反映を直したSwift42行と、その読み方

0
Posted at

最初にコード42行を置く。技術解説は後ろに回す。先にArtifactを読んで、判断を引っかけてから戻ってきてほしい。

import UIKit
import CoreData

final class HistoryReflector {
    typealias Snapshot = NSDiffableDataSourceSnapshot<Section, NSManagedObjectID>
    enum Section { case main }

    private weak var collectionView: UICollectionView?
    private weak var dataSource: UICollectionViewDiffableDataSource<Section, NSManagedObjectID>?
    private let store: NSPersistentContainer
    private var pendingApply: DispatchWorkItem?

    init(collectionView: UICollectionView,
         dataSource: UICollectionViewDiffableDataSource<Section, NSManagedObjectID>,
         store: NSPersistentContainer) {
        self.collectionView = collectionView
        self.dataSource = dataSource
        self.store = store
        NotificationCenter.default.addObserver(
            self, selector: #selector(didSave(_:)),
            name: .NSManagedObjectContextDidSave, object: store.viewContext)
    }

    @objc private func didSave(_ note: Notification) {
        pendingApply?.cancel()
        let delay: TimeInterval = inserted(note) ? 0.200 : 0.016
        let work = DispatchWorkItem { [weak self] in self?.applyLatestSnapshot() }
        pendingApply = work
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
    }

    private func applyLatestSnapshot() {
        guard let cv = collectionView, let ds = dataSource else { return }
        let req: NSFetchRequest<MemoMO> = MemoMO.fetchRequest()
        req.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
        req.fetchLimit = 200
        let ids = (try? store.viewContext.fetch(req).map(\.objectID)) ?? []
        var snap = Snapshot()
        snap.appendSections([.main])
        snap.appendItems(ids, toSection: .main)
        let animated = cv.window != nil && !cv.isDragging && !cv.isDecelerating
        ds.apply(snap, animatingDifferences: animated)
    }

    private func inserted(_ note: Notification) -> Bool {
        let s = note.userInfo?[NSInsertedObjectsKey] as? Set<NSManagedObject> ?? []
        return s.contains { $0 is MemoMO }
    }
}

これがCaptio式シンプルメモの HistoryReflector.swift 全文だ。実測42行。SwiftUIではなくUIKit。CombineではなくNotificationCenter。RxSwiftもReactorKitも入っていない。さらにNSFetchedResultsControllerも使っていない。

このたった42行に、9個の「やらなかった選択」が畳み込まれている。順番に剥がしていく。

このSwift 42行で起きていること

ユーザーがメモを送信すると、Core Dataの viewContextMemoMO エンティティが1件挿入される。挿入が起きると NSManagedObjectContextDidSave 通知が飛ぶ。HistoryReflector はこの通知を観測している。通知が来たら200ms待ってから (挿入の場合)、最新200件をフェッチして UICollectionViewDiffableDataSource にスナップショットを当てる。たったこれだけだ。

書いてしまえば数行で終わる。だが「200ms待つ」「200件で打ち切る」「windowが無い時はアニメーションを切る」「次の通知が来たら前のジョブをキャンセルする」、こうした判断が一行ずつに埋まっている。そして判断のそれぞれに、選ばなかった選択肢の死体が転がっている。順に拾い上げる。

なぜ NSFetchedResultsController を使わなかったか

iOS開発で Core Data のリストを表示するとき、まず思いつくのは NSFetchedResultsController (以下 NSFRC) だ。Apple純正のAPIで、デリゲートを設定すれば挿入・更新・削除をリアルタイムに教えてくれる。テーブルビューを performBatchUpdates で更新するためのプリミティブと言ってもいい。NSFRCを使う以外の選択肢を真剣に検討する個人開発者は、たぶんあまりいない。

検討した結果、Captio式シンプルメモでは外した。理由は3つある。

ひとつ目。「即時反映」がNSFRCの最大の売りだが、Historyの場合これが弱点になる。送信ボタンを押した瞬間にリストが動くと、送信完了アニメーション・キーボード降下・テキストフィールドのリセット、すべてが同時に走る。0.05秒の遅延が出るだけで「カクついた」と感じる。即時が要求でない場面で即時を提供すると、競合する他のアニメーションを壊す。

ふたつ目。NSFRCは controller(_:didChange:at:for:newIndexPath:) で一件ずつ通知してくる。これをDiffableDataSourceに橋渡しするには、結局自前でスナップショットを組み直すことになる。だったら最初から viewContext の保存通知1本で「最新スナップショットを丸ごと作る」方が、コードが30行ほど短くなる。

みっつ目。テスト容易性。NSFRCはインスタンス化に NSManagedObjectContextNSFetchRequest が要る上に、performFetch() の呼び忘れがバグの温床になる。NotificationCenterならフェイクの NSNotification を投げるだけで HistoryReflector の挙動をユニットテストできる。

代替テストコードを置く。

final class HistoryReflectorTests: XCTestCase {
    func test_insertedNotification_appliesSnapshotAfter200ms() {
        let (sut, ds, store) = makeSut()
        NotificationCenter.default.post(
            name: .NSManagedObjectContextDidSave,
            object: store.viewContext,
            userInfo: [NSInsertedObjectsKey: Set([MemoMO.fake(in: store.viewContext)])])
        let exp = expectation(description: "applied")
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
            XCTAssertGreaterThan(ds.snapshot().numberOfItems, 0)
            exp.fulfill()
        }
        waitForExpectations(timeout: 1.0)
    }
}

依存ライブラリゼロ、モックゼロ、ただの NotificationCenter.post 一発でテストが書ける。NSFRCを採用していたらこのテストは2倍の長さになっていた。

「NSFetchedResultsController 代替」で検索した人がこの記事に辿り着いてくれたなら、まず伝えたいのはこれだ。NSFRCは正解ではない、選択肢のひとつだ。

なぜ Combine ではなく NotificationCenter にしたか

2026年のiOS開発で「Combine使わないんですか」と聞かれる頻度は高い。理由は3つある。

ひとつ目。Combineの Publishers.MergeManydebounce(for:scheduler:) を使うと宣言的で美しく書ける。ただしCombineで書いた瞬間に「このコードはCombineで書かれている」という前提知識が読み手に要求される。HistoryReflector はCaptio式シンプルメモというアプリの中で「特殊なクラス」ではなく「ただの裏方」であってほしい。NotificationCenterなら10年前のiOS開発者でも読める。debounce オペレータをタブで開いて意味を調べる必要はない。

ふたつ目。Combineの AnyCancellable をどこで保持するかは、毎回の小さな悩みだ。var cancellables = Set<AnyCancellable>() に貯めて、deinit でクリアして、と書く。それくらいならNotificationCenterで observer を1つ登録するだけで済む。Selector-baseで登録すれば、removeObserver(self)deinit で呼ぶのを忘れても、iOS 9以降は弱参照で勝手にクリアされる。

みっつ目。Combineは非同期チェーン中にエラーが起きると、そのチェーン全体が completion で終わってしまう。HistoryReflector がエラーで死ぬとリストが二度と更新されない。NotificationCenterなら「次の通知が来たらまた動く」が自然な挙動になる。死なないアーキテクチャを書くには、まず死ぬ余地のないプリミティブを選ぶことだ。

なぜ DiffableDataSource を選んだか

UICollectionViewDiffableDataSource はiOS 13で導入された。それまでは performBatchUpdates でIndexPathをひとつずつinsert/deleteしていた時代だ。あの時代のコードは、IndexPathを計算ミスすると即クラッシュした。NSInternalInconsistencyException。本番アプリで一度でも遭遇した人は、二度と書きたくないと思っているはずだ。

DiffableDataSourceは内部でハッシュを取って差分を計算してくれる。IndexPathは触らなくていい。代わりに「最新状態のスナップショット」を渡せばよい。これは関数型の reducefold と相性がいい。

HistoryReflectorapplyLatestSnapshot() がやっていることは、要するに「最新のCore Data状態をスナップショットに変換して投げる」だけだ。差分計算はDiffableDataSourceが裏で全部やる。

ただし注意点。apply(_:animatingDifferences:) をmain以外から呼ぶとクラッシュする。これはApple純正APIリファレンスにも書いてあるが、Combineの .receive(on: DispatchQueue.main) を挟み忘れて事故ったことのある人は多いはずだ。HistoryReflector は最初から DispatchQueue.main.asyncAfter で起動しているので、この事故が起きる余地が無い。

なぜ 16ms と 200ms の2段ディレイを入れたか

ここが体感品質の核だ。

挿入 (送信成功) の場合は200ms待つ。なぜか。送信ボタンを押した直後、Captio式シンプルメモでは以下のアニメーションが平行して走る。

  • 送信ボタンが0.12秒で「送信中」表示に切り替わる
  • キーボードが0.25秒で降下する
  • テキストフィールドが空にリセットされる (0.05秒)
  • 入力ビュー全体が下にスライドして消える (0.20秒)

この最中にHistoryリストが上から「ぐにっ」と挿入アニメーションを始めると、画面が「ふたつ動いている」ように見える。0.20秒待てば、ほぼ全てのアニメーションが終わる頃にHistoryが静かに更新される。順番が「送る → アニメ完了 → Historyに増えた」になる。これは物理世界の因果と一致する。Historyが先に動くと「結果が原因より早く来た」感じになる。

削除や非挿入 (更新) の場合は16ms (1フレーム) で十分だ。削除は基本ユーザーがHistoryから明示的に行う操作なので、即応してほしい。1フレーム遅延を入れるのは、複数の削除通知が同フレームに連続して来た場合に「最後の通知が来てから当てる」ためのデバウンス用途だ。

「200ms」「16ms」という数字は、実際に5人の被験者に触ってもらって決めた。100msでは「Historyが先に動いた」感が残った。300msだと「あれ、更新されてない?」と聞かれた。間を取って200ms。再現性のある数字ではない。あなたのアプリでの200msは、たぶん180msでも220msでもない別の数字だ。

なぜ メインスレッドで必ず apply するか

これは前述の通りAppleの制約。DispatchQueue.main.asyncAfter を使うことで自然に保証している。「マルチスレッドで書きました」と言える設計はかっこいいが、UI更新は単一スレッドの方が事故が少ない。マルチスレッドを語るのは、シングルスレッドで詰まってからでいい。

なぜ スクロール中はアニメーションを止めるか

cv.isDragging || cv.isDecelerating のチェックがそれだ。スクロール中にDiffableDataSourceが animatingDifferences: trueapply すると、スクロールが「カクッ」と止まる瞬間がある。これはUIKitの制約で、コレクションビューの内部で再レイアウト計算が走るからだ。

スクロール中はアニメーションを切って、スナップショットだけ静かに差し替える。ユーザーは「スクロールが終わったらリストが少し違う」状態でHistoryの末尾まで辿り着く。これが一番気にならない挙動だった。

「iOS DiffableDataSource スクロール 止まる」で検索したら、たぶんこの記事しか出てこない。ニッチだが、触ると分かるレベルで品質が変わる。

なぜ window がない時は animatingDifferences を false にするか

cv.window != nil のチェックがそれだ。コレクションビューが画面階層から外れている時 (タブ切り替え後、モーダル裏に隠れている時) にアニメーション付きで apply すると、UIKit内部でレイアウト計算が走るが、結果は表示されない。CPUとバッテリーの無駄になる。

これは見えないところの最適化だ。Instrumentsの Time Profiler で気付いた。タブを切り替えた直後の1秒間、HistoryReflectorapply がバックグラウンドで7回走っていた。全てアニメーション付きで。windowチェックを1行入れただけで、その7回が0回になった。

let animated = cv.window != nil && !cv.isDragging && !cv.isDecelerating
ds.apply(snap, animatingDifferences: animated)

たった2行で、表に見えない無駄が消えた。表に見えない無駄を消すのは個人開発の主戦場だ。チームでやると優先度が落ちる。

fetchLimit 200 の根拠

Captio式シンプルメモは「忘れていくメモ」を設計思想にしている。だからHistoryは最新200件で打ち切る。100件にしようかとも思ったが、月に60件書く人 (1日2件ペース) でも3ヶ月分は残しておきたかった。500件にすると、初回スクロールで体感の重さが出始める。200は経験的な妥協点だ。

ただし200件以上のメモは消えない。SQLiteには全部残っている。検索機能 (Day10以降の予定) では fetchLimit を外して全件対象にする。Historyビューは「直近を流し見るための窓」と割り切る設計だ。窓の外側の世界は別のUIで見せる。

200という数字に根拠はない、と書きたいところだが、実は弱い根拠はある。iPhone 13サイズで縦に並べたとき、200件をスクロールするのにだいたい12秒かかる。12秒以上スクロールするユーザーは「探している」状態であり、それは検索機能の出番だ。だから200は「Historyを流し見る」最大単位として無理がない。

42行で削った3つの「便利機能」

(1) 部分更新。「特定の1件だけ更新する」インターフェースは作っていない。全件再フェッチ・全件適用が常に走る。200件なら問題ないので、特定更新の最適化はしない。

(2) ローディング状態。フェッチ中のスピナーは無い。100ms以内に終わるので不要だ。スピナーは、出すべきでない時に出すと「アプリが遅い」という印象だけが残る。

(3) エラーUI。fetchエラー時の例外UIは無い。(try? store.viewContext.fetch(req).map(\.objectID)) ?? [] で空配列にフォールバックする。Core Dataのfetchが失敗するときはアプリが詰んでいる時で、別の場所で表示すべきエラーだ。HistoryReflector の責務ではない。

「便利機能を作らない」も判断のうちだ。作らないという判断のほとんどは、書き始める前に判断しないと書いてしまう。書いてから「やっぱり消そう」は、書く前の3倍重い。

触ってわかる「反映が遅い」と「反映が雑」の境界

最後にひとつだけ伝えたいこと。

ユーザーは「反映が遅い」とは滅多に言わない。代わりに「アプリがなんか古い」「もっさり」と言う。逆に「反映が雑」とも言わない。「うちのスマホ調子悪いのかな」と言う。

開発者は「反映を速くする」を「即時反映」と読みがちだ。違う。反映を速くするとは、「ユーザーが反映を意識する瞬間に、反映が終わっている」を作ることだ。送信ボタンを押した瞬間は、送信ボタンの動きに意識が向いている。0.2秒後にユーザーがHistoryに目を移したとき、そこにメモがあればいい。逆に、視線がボタンにある瞬間にHistoryが動くと「視界の端で何か動いた」というノイズだけが残る。

これはUIの話に見えて、実はメンタルモデルの話だ。コードはほとんど書かないが、書くべき場所を間違えない設計の話だ。

Day1 で書いた「伝わるけど邪魔しない」UIの正体は、こういう判断の積み重ねだ。1行ごとの遅延、1行ごとのキャンセル、1行ごとの「やらない選択」。それを書くために、ライブラリの抽象は時に邪魔になる。Day6で「Apple純正だけ」を選んだ理由のひとつでもある。

42行を再掲する

ここまで読んでもう一度コードを置く。最初に読んだときとは違って見えるはずだ。

import UIKit
import CoreData

final class HistoryReflector {
    typealias Snapshot = NSDiffableDataSourceSnapshot<Section, NSManagedObjectID>
    enum Section { case main }

    private weak var collectionView: UICollectionView?
    private weak var dataSource: UICollectionViewDiffableDataSource<Section, NSManagedObjectID>?
    private let store: NSPersistentContainer
    private var pendingApply: DispatchWorkItem?

    init(collectionView: UICollectionView,
         dataSource: UICollectionViewDiffableDataSource<Section, NSManagedObjectID>,
         store: NSPersistentContainer) {
        self.collectionView = collectionView
        self.dataSource = dataSource
        self.store = store
        NotificationCenter.default.addObserver(
            self, selector: #selector(didSave(_:)),
            name: .NSManagedObjectContextDidSave, object: store.viewContext)
    }

    @objc private func didSave(_ note: Notification) {
        pendingApply?.cancel()
        let delay: TimeInterval = inserted(note) ? 0.200 : 0.016
        let work = DispatchWorkItem { [weak self] in self?.applyLatestSnapshot() }
        pendingApply = work
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
    }

    private func applyLatestSnapshot() {
        guard let cv = collectionView, let ds = dataSource else { return }
        let req: NSFetchRequest<MemoMO> = MemoMO.fetchRequest()
        req.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
        req.fetchLimit = 200
        let ids = (try? store.viewContext.fetch(req).map(\.objectID)) ?? []
        var snap = Snapshot()
        snap.appendSections([.main])
        snap.appendItems(ids, toSection: .main)
        let animated = cv.window != nil && !cv.isDragging && !cv.isDecelerating
        ds.apply(snap, animatingDifferences: animated)
    }

    private func inserted(_ note: Notification) -> Bool {
        let s = note.userInfo?[NSInsertedObjectsKey] as? Set<NSManagedObject> ?? []
        return s.contains { $0 is MemoMO }
    }
}

数字 (200, 0.200, 0.016)、guardチェック、それから DispatchWorkItem.cancel() の3つを、最初に読んだ時より重く読んでくれていたら、この記事の役目は果たせている。

「iOS DiffableDataSource スクロール 止まる」「NSFetchedResultsController 代替」の検索結果としてここに辿り着いた方には、ぜひこの42行を自分のプロジェクトに置いてみてほしい。1日触ると、自分のアプリでの「正しい数字」がたぶん違うところに見えてくる。

次回 Day8 に積んだメモ

HistoryReflector で残しているTODOがふたつある。

ひとつは「メモを削除した時の取り消しUI」。今は削除アニメーションが走った後、3秒以内にUndoしないと完全消去になる。3秒は短い気もする。逆に長くすると「もう消えたつもり」のメモが残り続けて、別の不安を生む。これはDay8で詰める。

もうひとつは「複数デバイス間のHistory同期」。Captio式シンプルメモは現状iCloud同期を外している (Day6の依存ゼロ判断と関連する)。同期するとしたら CKContainer か、それとも自前のメール経由か。これはDay9以降で迷う予定だ。

読んだ人へ

NSFetchedResultsController を「Apple純正の最適解」と思っていた頃の自分がこの記事を読んだら、たぶん最初は反発したと思う。それでいい。NSFRCは今でも多くの場面で最適解だ。ただ、Historyリストのように「即時反映が逆に邪魔になる」場面では、たった42行で代替できる場合がある。

ご自身のプロジェクトで「リスト反映のタイミング」に違和感がある方は、HistoryReflector の判断を1個ずつ自分の文脈で問い直してみてほしい。たぶん違う数字、違うプリミティブ、違う「やらない選択」が出てくるはずだ。それが出てきたらコメントで教えてほしい。次のDayでの判断材料にしたい。

Captio式シンプルメモ
外部依存ライブラリゼロ。SwiftとApple純正フレームワーク群だけで書いた、起動0.3秒の個人開発iOSアプリ。
App Store:https://apps.apple.com/jp/app/captio%E5%BC%8F%E3%82%B7%E3%83%B3%E3%83%97%E3%83%AB%E3%83%A1%E3%83%A2/id6749649498

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?