最初にコード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の viewContext に MemoMO エンティティが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はインスタンス化に NSManagedObjectContext と NSFetchRequest が要る上に、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.MergeMany や debounce(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は触らなくていい。代わりに「最新状態のスナップショット」を渡せばよい。これは関数型の reduce や fold と相性がいい。
HistoryReflector の applyLatestSnapshot() がやっていることは、要するに「最新の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: true で apply すると、スクロールが「カクッ」と止まる瞬間がある。これはUIKitの制約で、コレクションビューの内部で再レイアウト計算が走るからだ。
スクロール中はアニメーションを切って、スナップショットだけ静かに差し替える。ユーザーは「スクロールが終わったらリストが少し違う」状態でHistoryの末尾まで辿り着く。これが一番気にならない挙動だった。
「iOS DiffableDataSource スクロール 止まる」で検索したら、たぶんこの記事しか出てこない。ニッチだが、触ると分かるレベルで品質が変わる。
なぜ window がない時は animatingDifferences を false にするか
cv.window != nil のチェックがそれだ。コレクションビューが画面階層から外れている時 (タブ切り替え後、モーダル裏に隠れている時) にアニメーション付きで apply すると、UIKit内部でレイアウト計算が走るが、結果は表示されない。CPUとバッテリーの無駄になる。
これは見えないところの最適化だ。Instrumentsの Time Profiler で気付いた。タブを切り替えた直後の1秒間、HistoryReflector の apply がバックグラウンドで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