0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【個人開発】SwiftUI + UIDocument + iCloud DriveでZettelkastenノートアプリをリリースした話 — 実装で詰まった3つのポイント

0
Posted at

はじめに

iCloud Drive上のMarkdownファイルを管理するiOSノートアプリ Nodus を個人開発し、App Storeにリリースした。

本記事では、実装中に詰まった以下の3点を中心に解説する。同じ構成でアプリを作ろうとしている方の参考になれば嬉しい。

  1. NSMetadataQueryで外部変更が検知できない問題
  2. NavigationStack内でonChangeが届かない問題
  3. 全文検索で未開封ノートが対象外になる問題

作ったもの

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
)

そのため、SearchEnginenote.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で使う

@AppStorageinit 内で直接読めない。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 で受け付けています。


参考

0
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?