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

Swiftでデータ内部保存できる機能を比較してみた

1
Last updated at Posted at 2026-03-31

Swiftでデータ内部保存できる機能を比較してみた

本記事の文章作成には生成AIを補助的に利用しています。内容は確認していますが、記載に誤りがある可能性があります。

iOSアプリで「内部保存」を実装するとき、選択肢が多く、個人的に実際に使ったことがないものが多くあります。
この記事では、同じTODOアプリを題材に以下6方式を比較します。

  • UserDefaults
  • Keychain
  • File(JSON/CSV)
  • Core Data
  • SwiftData
  • Realm

本記事のコードは DataStore プロジェクトで実際に動かしている実装です。

この記事で比較する観点

  • どんなデータに向くか
  • CRUD(登録・読み込み・更新・削除)をどう書くか
  • メリット/注意点

先に結論

  • 軽量設定値: UserDefaults
  • 機密データ: Keychain
  • 可搬性・人が読める形式: File(JSON/CSV)
  • 複雑なデータ構造・既存資産: Core Data
  • iOS 17+でSwiftらしく書く: SwiftData
  • クロスプラットフォーム寄り・軽快なオブジェクトDB: Realm

比較表(要点)

方式 主用途 保存形式 向いているケース 注意点
UserDefaults 設定・小さな状態 Key-Value フラグ、軽量リスト 大量/機密データには不向き
Keychain 秘密情報 Key-Value(暗号化保護) トークン、パスワード APIが低レベルで少し複雑
File 自由なファイル保存 JSON/CSV等 互換性・エクスポート 同時更新や整合性を自前で管理
Core Data Apple標準ORM SQLite等(内部管理) 複雑クエリ/実績重視 学習コストあり
SwiftData 新しい永続化API SwiftData管理 SwiftUI連携重視 対応OS条件を確認
Realm サードパーティDB Realm独自形式 直感的なオブジェクト操作 依存追加が必要

比較表(実務向け・相対評価)

方式 セキュリティ データ量 検索・絞り込み スキーマ変更 実装コスト
UserDefaults 弱い 手動対応
Keychain 弱い 手動対応
File(JSON/CSV) 低〜中 小〜中 弱い(自前実装) 手動対応 低〜中
Core Data 中〜大 強い 比較的強い
SwiftData 中〜強 比較的強い
Realm 中〜大 強い 比較的強い

※ この表は「本記事のTODOアプリ実装」を前提にした相対評価です。要件(監査要件、データ量、オフライン戦略など)で最適解は変わります。

画面操作とCRUDの対応

全タブでUIは統一しており、操作は同じです。違うのは保存先だけです。

画面操作 意味 内部で呼ぶ処理
+ ボタンで追加 登録(Create) add(...) / save(...)
行タップで編集 更新(Update) update(...)
左スワイプ削除 削除(Delete) delete(...)
画面表示時に一覧表示 読み込み(Read) reload() / load()
チェックボタン 完了状態更新(Update) toggleDone(...)

1. UserDefaults

使える機能

  • Key-Value保存
  • Bool / Int / String / Data / Array / Dictionary はそのまま保存可能
  • 独自構造体は CodableData(例: Property List)にして保存
  • 小さいデータの高速な読み書き

CRUDコード(実装例)

final class UserDefaultsTodoService {
    private let key = "ud_todos"
    private let defaults = UserDefaults.standard

    // Read
    func load() throws -> [UserDefaultsTodo] {
        guard let data = defaults.data(forKey: key) else { return [] }
        return try PropertyListDecoder().decode([UserDefaultsTodo].self, from: data)
    }

    // Create/Update(一括保存)
    func save(_ todos: [UserDefaultsTodo]) throws {
        let data = try PropertyListEncoder().encode(todos)
        defaults.set(data, forKey: key)
    }
}

PropertyListEncoder/Decoder が必要なのは、UserDefaultsTodo のような独自型を Data に変換して保存するためです。
BoolString のような標準型だけなら、エンコード自体が不要です。

単純な値だけなら、次のようにJSON化なしで直接保存できます。

// 直接保存
UserDefaults.standard.set(true, forKey: "hasOnboarded")
// 直接読み込み
let hasOnboarded = UserDefaults.standard.bool(forKey: "hasOnboarded")

2. Keychain

使える機能

  • 秘密情報を安全に保存
  • SecItemUpdate / SecItemAdd / SecItemCopyMatching で管理
  • App再起動後も保持

CRUDコード(実装例)

final class KeychainTodoService {
    private let account = "kc_todos"

    // Create/Update(既存は更新、未作成なら追加)
    func save(todos: [UserDefaultsTodo]) throws {
        let data = try JSONEncoder().encode(todos)
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account
        ]
        let updateAttributes: [String: Any] = [
            kSecValueData as String: data
        ]
        let updateStatus = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary)
        if updateStatus == errSecSuccess { return }
        if updateStatus != errSecItemNotFound {
            throw NSError(domain: "KeychainError", code: Int(updateStatus), userInfo: nil)
        }

        var addAttributes = query
        addAttributes[kSecValueData as String] = data
        let addStatus = SecItemAdd(addAttributes as CFDictionary, nil)
        guard addStatus == errSecSuccess else {
            throw NSError(domain: "KeychainError", code: Int(addStatus), userInfo: nil)
        }
    }

    // Read
    func load() throws -> [UserDefaultsTodo] {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        var item: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        if status == errSecItemNotFound { return [] }
        guard status == errSecSuccess, let data = item as? Data else {
            throw NSError(domain: "KeychainError", code: Int(status), userInfo: nil)
        }
        return try JSONDecoder().decode([UserDefaultsTodo].self, from: data)
    }

}

3. File(JSON/CSV)

使える機能

  • Documents配下へ自由に保存
  • JSONで永続化、CSVで外部連携
  • ユーザー向けエクスポートが作りやすい

CRUDコード(実装例)

final class FileTodoService {
    private var documentsURL: URL {
        FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
    }

    var jsonFileURL: URL { documentsURL.appendingPathComponent("todos.json") }
    var csvFileURL: URL { documentsURL.appendingPathComponent("todos.csv") }

    // Create/Update
    func saveJSON(_ todos: [UserDefaultsTodo]) throws {
        let data = try JSONEncoder().encode(todos)
        try data.write(to: jsonFileURL, options: .atomic)
    }

    // Read
    func loadJSON() throws -> [UserDefaultsTodo] {
        guard FileManager.default.fileExists(atPath: jsonFileURL.path) else { return [] }
        let data = try Data(contentsOf: jsonFileURL)
        return try JSONDecoder().decode([UserDefaultsTodo].self, from: data)
    }

    // Export
    @discardableResult
    func exportCSV(_ todos: [UserDefaultsTodo]) throws -> URL {
        let header = "id,title,detail,isDone,createdAt\n"
        let rows = todos.map { todo in
            [
                todo.id.uuidString,
                "\"\(todo.title.replacingOccurrences(of: "\"", with: "\"\""))\"",
                "\"\(todo.detail.replacingOccurrences(of: "\"", with: "\"\""))\"",
                todo.isDone ? "true" : "false",
                ISO8601DateFormatter().string(from: todo.createdAt)
            ].joined(separator: ",")
        }
        let csv = header + rows.joined(separator: "\n")
        guard let data = csv.data(using: .utf8) else {
            throw NSError(domain: "FileTodoService", code: -1, userInfo: [NSLocalizedDescriptionKey: "CSVエンコードに失敗しました"])
        }
        try data.write(to: csvFileURL, options: .atomic)
        return csvFileURL
    }
}

4. Core Data

使える機能

  • エンティティ管理、フェッチ、ソート、更新、削除
  • 永続化ストアをApple標準で扱える
  • アプリ規模拡大に耐えやすい

CRUDコード(実装例)

@MainActor
final class CoreDataTodoViewModel: ObservableObject {
    @Published var todos: [CoreDataTodo] = []
    private let context: NSManagedObjectContext

    init(context: NSManagedObjectContext) {
        self.context = context
        reload()
    }

    // Read
    func reload() {
        let request = CoreDataTodo.fetchRequestAll()
        todos = (try? context.fetch(request)) ?? []
    }

    // Create
    func add(title: String, detail: String) {
        let todo = CoreDataTodo(context: context)
        todo.id = UUID()
        todo.title = title
        todo.detail = detail
        todo.isDone = false
        todo.createdAt = Date()
        try? context.save()
        reload()
    }

    // Update
    func update(todo: CoreDataTodo, title: String, detail: String) {
        todo.title = title
        todo.detail = detail
        try? context.save()
        reload()
    }

    // Delete
    func delete(at offsets: IndexSet) {
        for index in offsets {
            context.delete(todos[index])
        }
        try? context.save()
        reload()
    }
}

5. SwiftData

使える機能

  • @Model でモデル定義
  • ModelContext で insert/fetch/delete/save
  • SwiftUIとの統合がしやすい

CRUDコード(実装例)

@Model
final class SwiftDataTodo {
    @Attribute(.unique) var id: UUID
    var title: String
    var detail: String
    var isDone: Bool
    var createdAt: Date

    init(id: UUID = UUID(), title: String, detail: String, isDone: Bool = false, createdAt: Date = Date()) {
        self.id = id
        self.title = title
        self.detail = detail
        self.isDone = isDone
        self.createdAt = createdAt
    }
}

@MainActor
final class SwiftDataTodoViewModel: ObservableObject {
    @Published var todos: [SwiftDataTodo] = []
    private let context: ModelContext

    init(context: ModelContext) {
        self.context = context
        reload()
    }

    // Read
    func reload() {
        let descriptor = FetchDescriptor<SwiftDataTodo>(
            sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
        )
        todos = (try? context.fetch(descriptor)) ?? []
    }

    // Create
    func add(title: String, detail: String) {
        context.insert(SwiftDataTodo(title: title, detail: detail))
        try? context.save()
        reload()
    }

    // Update
    func update(todo: SwiftDataTodo, title: String, detail: String) {
        todo.title = title
        todo.detail = detail
        try? context.save()
        reload()
    }

    // Delete
    func delete(at offsets: IndexSet) {
        for index in offsets {
            context.delete(todos[index])
        }
        try? context.save()
        reload()
    }
}

6. Realm

使える機能

  • Object モデルをそのまま保存
  • realm.write {} でトランザクション管理
  • ソートやフィルタ、リアクティブ更新を行いやすい

CRUDコード(実装例)

import RealmSwift

final class RealmTodo: Object, ObjectKeyIdentifiable {
    @Persisted(primaryKey: true) var id: ObjectId
    @Persisted var title: String = ""
    @Persisted var detail: String = ""
    @Persisted var isDone: Bool = false
    @Persisted var createdAt: Date = Date()
}

@MainActor
final class RealmTodoViewModel: ObservableObject {
    @Published var todos: [RealmTodo] = []
    private let realm = try? Realm()

    // Read
    func reload() {
        guard let realm else { return }
        todos = Array(realm.objects(RealmTodo.self).sorted(byKeyPath: "createdAt", ascending: false))
    }

    // Create
    func add(title: String, detail: String) {
        guard let realm else { return }
        let todo = RealmTodo()
        todo.title = title
        todo.detail = detail
        try? realm.write { realm.add(todo) }
        reload()
    }

    // Update
    func update(todo: RealmTodo, title: String, detail: String) {
        guard let realm else { return }
        try? realm.write {
            todo.title = title
            todo.detail = detail
        }
        reload()
    }

    // Delete
    func delete(at offsets: IndexSet) {
        guard let realm else { return }
        try? realm.write {
            for index in offsets {
                realm.delete(todos[index])
            }
        }
        reload()
    }
}

どれを選ぶべきか

  • 個人アプリの軽量設定: UserDefaults
  • ログイン情報やAPIキー: Keychain
  • バックアップや人間可読な検証: File(JSON/CSV)
  • 長期運用の業務アプリ: Core Data
  • iOS 17以降でSwiftUI中心: SwiftData
  • 既にRealm知見がある/クロス展開したい: Realm

参考

実際のコード
https://github.com/kosukesh34/DataStore.git

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