はじめに
iCloud Drive上のMarkdownファイルを管理するiOSノートアプリ Nodus を個人開発し、App Storeにリリースした。
本記事では、実装中に詰まった以下の3点を中心に解説する。同じ構成でアプリを作ろうとしている方の参考になれば嬉しい。
- NSMetadataQueryで外部変更が検知できない問題
- NavigationStack内でonChangeが届かない問題
- 全文検索で未開封ノートが対象外になる問題
作ったもの
Nodus — iOS向けZettelkastenノートアプリ
- プレーンMarkdownファイルをiCloud Driveに保存
-
[[タイムスタンプID]]形式のwikiリンク - 全文AND検索(ファイル名+本文)
- GFM対応Markdownプレビュー(WKWebView + marked.js)
- The Archive・iA Writer・Obsidianと同フォルダで共存可能
App Store | GitHub
動作環境
| 項目 | バージョン |
|---|---|
| Xcode | 26.3 |
| Swift | 6 |
| iOS Deployment Target | 17.0+ |
| 外部ライブラリ | なし(marked.jsのみCDN経由) |
アーキテクチャ概要
Nodus/
├── Models/
│ ├── Note.swift ← Identifiable/Hashable な値型
│ └── NoteDocument.swift ← UIDocument サブクラス
├── Views/
│ ├── ContentView.swift ← NavigationSplitView のルート
│ ├── NoteListView.swift ← 検索・ソート・削除
│ └── NoteDetailView.swift ← 編集/プレビュー・自動保存
├── ViewModels/
│ └── NoteStore.swift ← iCloud CRUD・NSMetadataQuery
└── Core/
├── SearchEngine.swift ← AND検索(純粋関数)
└── LinkResolver.swift ← [[ID]]解決(純粋関数)
設計の基本方針
- ノート一覧は NSMetadataQuery で監視(外部エディタの変更も検知)
- ノートの読み書きは UIDocument 経由(iCloud同期・競合解決を自動化)
- 本文は遅延ロード(一覧表示時は
body = ""、詳細表示時にファイルから読む)
なぜUIDocumentを使うか
最初は FileManager で直接ファイルを読み書きする実装だった。しかしこの方法では以下の問題が発生した:
- The Archiveなどの外部エディタが同じファイルを編集した際、内容が競合して上書きされる
- iCloudの同期タイミングとアプリの保存タイミングが衝突する
- 外部変更の検知を手動で実装する必要があり、コードが複雑になる
UIDocumentはAppleが提供するドキュメント管理の基盤クラスで、これらをすべて内部で処理してくれる。
| 機能 | 直接FileManager | UIDocument |
|---|---|---|
| iCloud同期 | 手動 | 自動 |
| 外部変更検知 | NSMetadataQuery手動実装 | 自動(revert経由) |
| 競合解決 | なし | 自動検知・通知 |
| 保存タイミング | 手動 | autosave対応 |
NoteDocumentの実装
import UIKit
final class NoteDocument: UIDocument {
var body: String = ""
/// 外部変更で body が更新されたときのコールバック
var onExternalChange: (() -> Void)?
// MARK: - 読み込み
// UIDocument が外部変更を検知すると自動でこのメソッドを呼ぶ
override func load(fromContents contents: Any, ofType typeName: String?) throws {
guard let data = contents as? Data else {
body = ""
return
}
let newBody = String(data: data, encoding: .utf8) ?? ""
// 初回ロードと外部変更による再ロードを区別する
// body が空でない かつ 内容が変わった場合のみ外部変更と判定
let isExternalChange = !body.isEmpty && newBody != body
body = newBody
if isExternalChange {
DispatchQueue.main.async { [weak self] in
self?.onExternalChange?()
}
}
}
// MARK: - 書き込み
// save(to:for:) を呼ぶと UIDocument がこのメソッドを呼ぶ
override func contents(forType typeName: String) throws -> Any {
return body.data(using: .utf8) ?? Data()
}
}
NoteStoreでのDocument管理
開いているノートの NoteDocument を1つだけキャッシュする。別のノートに切り替わったら close() → open() する。
@MainActor
final class NoteStore: ObservableObject {
private var openDocument: NoteDocument?
private var openDocumentURL: URL?
/// 指定URLのNoteDocumentを開いて返す(同URLなら再利用)
func openDocument(for url: URL) async throws -> NoteDocument {
// 同じURLなら既存インスタンスを再利用
if let doc = openDocument, openDocumentURL == url {
return doc
}
// 別のノートに切り替わった場合は前のDocumentを閉じる
if let doc = openDocument {
await doc.close()
}
let doc = NoteDocument(fileURL: url)
try await doc.open() // UIDocumentがload(fromContents:)を呼ぶ
// 外部変更のコールバックを設定
doc.onExternalChange = { [weak self] in
guard let self else { return }
self.lastExternalUpdateDate = Date()
}
openDocument = doc
openDocumentURL = url
return doc
}
/// 保存
func saveNote(_ note: Note) {
Task {
do {
let doc = try await openDocument(for: note.url)
doc.body = note.body
try await doc.save(to: note.url, for: .forOverwriting)
await MainActor.run {
if let index = notes.firstIndex(where: { $0.url == note.url }) {
notes[index].updatedAt = Date()
}
}
} catch {
print("❌ saveNote error: \(error)")
}
}
}
/// 本文の読み込み(キャッシュがあればDocumentから、なければファイルから)
func loadBody(for note: Note) -> String {
if let doc = openDocument, openDocumentURL == note.url {
return doc.body
}
return (try? String(contentsOf: note.url, encoding: .utf8)) ?? ""
}
/// 非同期版(.task内で使う)
func loadBodyAsync(for note: Note) async -> String {
do {
let doc = try await openDocument(for: note.url)
return doc.body
} catch {
return (try? String(contentsOf: note.url, encoding: .utf8)) ?? ""
}
}
}
詰まったポイント1:NSMetadataQueryで外部変更が検知できない
問題
外部エディタ(The Archive等)がファイルを変更したとき、NSMetadataQueryの通知が来ないケースがあった。
原因
searchScopes にフォルダのURLを直接渡すと、iCloud Driveのユビキタスコンテナ外のパスでは通知が来ない場合がある。
// ❌ これだと実機で通知が来ないことがある
query.searchScopes = [folderURL]
解決策
NSMetadataQueryUbiquitousDocumentsScope を使い、パスのプレフィックスでフォルダを絞り込む。
func startMonitoring(folderURL: URL) {
let query = NSMetadataQuery()
// ✅ iCloudのユビキタスDocumentsスコープで監視
query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
// パスのプレフィックスとファイル拡張子で対象を絞る
query.predicate = NSPredicate(
format: "%K BEGINSWITH %@ AND %K ENDSWITH[c] '.md'",
NSMetadataItemPathKey,
folderURL.path,
NSMetadataItemFSNameKey
)
// 通知を購読
NotificationCenter.default.addObserver(
forName: .NSMetadataQueryDidUpdate,
object: query,
queue: .main
) { [weak self] _ in
self?.handleMetadataUpdate()
}
query.start()
}
/// デバウンスして一覧を再読み込み
func handleMetadataUpdate() {
metadataReloadDebounceWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.loadNotes()
self?.lastExternalUpdateDate = Date()
}
metadataReloadDebounceWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work)
}
注意点
-
startAccessingSecurityScopedResource()/stopAccessingSecurityScopedResource()のバランスに注意(監視中はアクセスを維持する) - デバウンス処理は必須(短時間に大量の通知が来ることがある)
詰まったポイント2:NavigationStack内でonChangeが届かない
問題
NavigationSplitView の詳細列に NavigationStack を入れてwikiリンク遷移を実装している。この構成で @EnvironmentObject のプロパティに onChange(of:) を付けると、iPad実機でNavigationStack内から通知が届かないことがある。
// ❌ NavigationStack内でEnvironmentObjectのonChangeが届かないことがある
.onChange(of: store.lastExternalUpdateDate) { _, date in
// 実機のiPadで発火しないケースがある
reloadIfNeeded()
}
解決策
onChange(of:) をやめて、Combineの onReceive で @Published プロパティのPublisherを直接購読する。
import Combine
// ✅ @PublishedのPublisherをonReceiveで直接購読する
.onReceive(store.$lastExternalUpdateDate) { date in
// distantPastは初期値なので無視
guard date > .distantPast else { return }
if isEditing {
// 編集中は外部変更フラグを立てるだけ
hasExternalChange = true
} else {
// プレビュー中は即時再読み込み
let body = store.cachedBody(for: currentNote) ?? store.loadBody(for: currentNote)
if !body.isEmpty, body != loadedBody {
loadedBody = body
}
}
}
なぜこれで解決するか
onChange(of:) はSwiftUIのビュー更新サイクルに依存するため、NavigationStack の深い階層では通知が間引かれることがある。一方、onReceive はCombineのストリームを直接購読するため、ビュー階層に依存しない。
詰まったポイント3:全文検索で未開封ノートが対象外になる
問題
NoteStore.loadNotes() はパフォーマンスのため、本文を遅延ロードする(ファイルのメタデータのみ取得、body = "")。
// loadNotes()内
return Note(
url: url,
body: "", // ← 本文は空文字で初期化
createdAt: createdAt,
updatedAt: updatedAt
)
そのため、SearchEngine が note.body を検索対象にしても、一度も開いていないノートは本文が空のまま検索から除外される。
解決策
検索クエリがあるときだけ全ノートの本文を読み込む。
/// 検索用に全ノートの本文を読み込んで返す
/// パフォーマンスコストが高いため、クエリがある場合のみ呼ぶ
func notesWithBody() -> [Note] {
return notes.map { note in
var n = note
// キャッシュ済みDocumentがあればそこから、なければファイルから読む
n.body = cachedBody(for: note) ?? loadBody(for: note)
return n
}
}
// 検索時のみ全文読み込み(空クエリなら従来の軽量リストを使う)
let searchTarget = query.isEmpty ? store.notes : store.notesWithBody()
let results = SearchEngine.search(query, in: searchTarget)
SearchEngineの実装
enum SearchEngine {
/// スペース区切りのAND検索
static func search(_ query: String, in notes: [Note]) -> [Note] {
let terms = query
.lowercased()
.split(separator: " ")
.map(String.init)
guard !terms.isEmpty else { return notes }
return notes.filter { note in
let searchTarget = (note.filename + " " + note.body).lowercased()
return terms.allSatisfy { searchTarget.contains($0) }
}
}
}
NavigationSplitViewのiPhone/iPad両対応
NavigationSplitView はiPad(Regular幅)でサイドバー+詳細の2カラム、iPhone(Compact幅)でプッシュナビゲーションに自動的に切り替わる。
struct ContentView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var selectedNoteID: String?
var body: some View {
if horizontalSizeClass == .regular {
// iPad: 2カラムレイアウト
NavigationSplitView {
NoteListView(selection: $selectedNoteID)
// ⚠️ サイドバー列にNavigationStackを入れない
} detail: {
if let id = selectedNoteID,
let note = store.notes.first(where: { $0.id == id }) {
NoteDetailView(note: note, embedInNavigationStack: true)
.id(id) // ノート切替でStateをリセット
}
}
} else {
// iPhone: プッシュナビゲーション
NoteListView()
}
}
}
ポイント:
- サイドバー列に
NavigationStackを入れない(wikiリンク遷移が二重になる) - 詳細列では
embedInNavigationStack: trueを渡してwikiリンク用のNavigationStackを内包させる -
.id(id)でノート切替時に@Stateをリセットする
@AppStorageをinitで使う
@AppStorage は init 内で直接読めない。UserDefaults 経由で回避する。
init(note: Note, embedInNavigationStack: Bool = false) {
_currentNote = State(initialValue: note)
_editingTitle = State(initialValue: note.title)
// @AppStorageはinit内で直接読めないためUserDefaultsを使う
let savedMode = UserDefaults.standard.string(forKey: "defaultEditorMode") ?? "edit"
_isEditing = State(initialValue: savedMode != "preview")
}
これをやらないと @State の初期値がハードコードされ、SettingsのDefault Mode設定が初回表示に反映されない。
まとめ
| 詰まりポイント | 原因 | 解決策 |
|---|---|---|
| NSMetadataQueryが発火しない | searchScopesに直接URLを渡していた | UbiquitousDocumentsScopeを使いパス絞り込み |
| onChangeが届かない | NavigationStack内でSwiftUIのビュー更新が間引かれる | onReceiveでCombineのPublisherを直接購読 |
| 全文検索が機能しない | 本文の遅延ロードで未開封ノートのbodyが空 | 検索時だけnotesWithBody()で全本文を読み込む |
SwiftUI + UIDocument + iCloud Driveの組み合わせはドキュメントが散逸していて詰まりやすいが、一つずつ解決していくと堅牢な実装になった。
同じ構成で開発している方の参考になれば嬉しい。フィードバックはコメントまたは GitHub Issues で受け付けています。