
File-backed ephemeral local store for large working data: survives process kills, auto-deletes with UI task/scene lifecycle, synchronous lightweight mutations, list/map APIs, change notifications and Flow.
作業中データのための Kotlin Multiplatform ローカルストア(Android / iOS)。プロセスキルでは消えず、タスク(iOS では scene)を閉じると消える。
「大きすぎて Bundle(iOS では state restoration)に入らないが、飛んでも惜しくない」データ — 未送信の編集列、オフライン操作キュー、ページング済みリスト — のための置き場。実体は saved instance state と同じ寿命を持つファイルバックのリスト/マップで、保存も削除も明示的な操作が要らない。
永続データ(再インストールやタスク破棄をまたいで残すもの)には向かない。それは Room / DataStore の領分。 設定・フラグのような恒久的な key-value データも jotter の領分ではない。それは daybook の領分。
| ターゲット | 保証水準 |
|---|---|
| Android(minSdk 21+) | 全機能。これまでと同じ使い方(Jotter.attach → list / map)。CI は JVM 上の Robolectric ユニットテスト、実機スモーク(ProcessKillSmokeTest)はエミュレータでの手動実行 |
| iOS(iosArm64 / iosSimulatorArm64) | 全機能。Jotter.autoAttach() による scene 寿命への自動結線、自動 flush、discard 掃除、LRU、バックアップ除外まで提供。検証は GHA の iOS シミュレータ上での XCTest 回帰(ios-device-test.yml) |
| jvm | jotter-core のエンジンのみ。タスク寿命への結線は持たず、JotterStore を直接使う(ディレクトリを開く JotterStore.open だけ @JotterInternalApi opt-in が必要) |
KMP 共有モジュールを組む場合は commonMain に jotter-core(+ jotter-coroutines)を置き、Android のタスク寿命への結線だけ Android 側モジュールで jotter を足す形になる。 Android アプリ単体で使う分には jotter-core を直接意識する必要はない。jotter を依存に加えれば jotter-core は推移的に付いてくる。 iOS から使う場合は jotter-core を直接依存に加える(別モジュールは不要)。
同じ問題領域(作業中データの一時保存)の選択肢との比較。それぞれ得意分野が違うので、必要な軸で選ぶこと。
| onSaveInstanceState(Bundle) | SavedStateHandle | Room / DataStore を一時データに使う | jotter | |
|---|---|---|---|---|
| プロセスキル生存 | ○ | ○ | ○ | ○ |
| サイズ制限なし | ×(実質 1MB 弱) | ×(実質 1MB 弱) | ○ | ○ |
| タスク終了で自動削除 | ○ | △(ViewModel 寿命どまり) | ×(自前で削除コードが必要) | ○ |
| 明示的な保存操作が不要 | ×(onSaveInstanceState を実装) | △(key-value の set はいる) | ×(永続化は自前呼び出し) | ○ |
| 変更通知・Flow | × | ○(StateFlow) | ○ | ○(jotter-coroutines) |
| 恒久データ向き | × | × | ○ | × |
同カテゴリの中では SavedStateHandle が最も近い(変更通知を持つ点も含めて)が、サイズ制限とタスク終了時の掃除の要否で線引きが変わる。 この表は Android の代替手段との比較で、iOS 版には onSaveInstanceState / SavedStateHandle に相当する OS 標準 API がないため対象外。
Maven Central から取得できる(リポジトリに mavenCentral() が入っていればそのまま使える)。
Android / iOS 両対応の KMP 共有モジュールを組む場合の形。commonMain に jotter-core を置けば両 OS に効く。Android のタスク寿命への結線(Jotter.attach(activity))だけは Android 側モジュールに jotter を足す。
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.kr9ly:jotter-core:2.0.0")
implementation("io.github.kr9ly:jotter-coroutines:2.0.0") // Flow で受けたい場合のみ
}
androidMain.dependencies {
implementation("io.github.kr9ly:jotter:2.0.0") // Jotter.attach(activity) を使う場合
}
}
}iOS 側は jotter-core の appleMain に結線層(Jotter.autoAttach() / Jotter.store(scene))が同梱されているため、上記の commonMain 依存だけで足りる。追加の iOS 専用依存は不要。
KMP 構成を組まず、Android アプリだけで使う場合の形。以下は AGP 8 系までの一般的な消費側の書き方(kotlin-android プラグインを明示適用する)。AGP 9 のビルトイン Kotlin を使う構成では kotlin-android の適用は不要(jotter 自身のビルドはこの方式)。
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.0" // Kotlin プラグインと同じバージョンにする
}
dependencies {
implementation("io.github.kr9ly:jotter:2.0.0")
implementation("io.github.kr9ly:jotter-coroutines:2.0.0") // Flow で受けたい場合のみ
}KMP プロジェクトの iOS ターゲット(iosMain 等)に jotter-core を直接依存として追加する場合の形。別モジュールは不要。
kotlin {
sourceSets {
iosMain.dependencies {
implementation("io.github.kr9ly:jotter-core:2.0.0")
}
}
}保存する型(@Serializable なデータクラス)の直列化に kotlinx.serialization を使うため、いずれの形でも Kotlin plugin を合わせて適用すること(バージョンは Kotlin プラグインと同じにする)。
要件: Kotlin 2.0+ / Android は minSdk 21+
以下はリストへのハンドル(JotterList / JotterMap)を既に持っている前提の共通操作。JotterList / JotterMap / ChangeEvent はプラットフォーム共通の型なので、コード例は Android・iOS のどちらでもそのまま使える。
例中の jotter は attach で得たストア、drafts はそこから開いたハンドル。取得手順(attach)はプラットフォーム別 — 次節「attach — 寿命への結線」を参照。
レコードは安定 ID つき。読み取りは常にメモリから同期で返る。
drafts.add("d1", Draft("d1", "hello"))
drafts.update("d1", Draft("d1", "hello, world"))
drafts.remove("d1")
drafts.clear()
drafts.addAll(pages.associateBy { it.id }) // 一括 upsert(通知 1 回)
drafts.replaceAll(fresh.associateBy { it.id }) // 丸ごと差し替え(通知 1 回、途中の空状態なし)
drafts.snapshot() // List<Draft>(挿入順)
drafts.get("d1") // Draft?
if ("d1" in drafts) { ... }
drafts.size
drafts.isEmpty()update で値が変わらない場合(直列化結果が同一)は書き込みも通知も発生しない。 addAll / replaceAll も同じ差分検出を通るため、リフレッシュ結果が前回と同一なら通知ゼロで済む。
同じ名前をソート順つきで開くと、snapshot がソート済みで返る。
val byUpdated = jotter.list<Draft>("drafts", compareByDescending { it.updatedAt })
val byTitle = jotter.list<Draft>("drafts", compareBy { it.title })
byUpdated.snapshot() // 更新日時降順同じ名前を id → value の Map としても扱える。
val drafts = jotter.map<Draft>("drafts")
drafts["d1"] = Draft("d1", "hello") // upsert
val prev = drafts.put("d1", draft) // 直前の値が返る
drafts.remove("d1") // 取り除いた値が返る
drafts.clear()
drafts.putAll(items) // 一括 upsert(通知 1 回)
drafts.replaceAll(items) // 丸ごと差し替え(通知 1 回)
if ("d1" in drafts) { ... }
drafts.snapshot() // Map<String, Draft>(挿入順)同名の list と実体を共有するので、同じデータを List と Map の両方のイディオムで操作できる。
val listener = ChangeListener { render(drafts.snapshot()) }
drafts.addListener(listener)
// 不要になったら
drafts.removeListener(listener)ChangeListener は値を運ばない — 受け手が snapshot を引く。変更内容(何がどう変わったか)が必要なら ChangeEventListener で ChangeEvent(Added / Updated / Removed と id・値)を受け取れる。
import io.github.kr9ly.jotter.coroutines.events
import io.github.kr9ly.jotter.coroutines.snapshots
lifecycleScope.launch {
drafts.snapshots().collect { render(it) } // 現在値を即時 emit + 変更ごとに最新
}
lifecycleScope.launch {
drafts.events().collect { event -> // Flow<ChangeEvent<Draft>>
when (event) {
is ChangeEvent.Added -> ...
is ChangeEvent.Updated -> ...
is ChangeEvent.Removed -> ...
}
}
}snapshots() は conflate — 高頻度変更時は中間状態をスキップして最新だけが届くevents() は取りこぼしなし — 収集開始後の全イベントが順に流れるハンドル(JotterList / JotterMap)を得るための最初の一手は、寿命の単位が Android(タスク)と iOS(scene)で異なるため、プラットフォームごとに手順が分かれる。
Activity の onCreate(super の後)で attach し、名前つきリストを開く。
@Serializable
data class Draft(val id: String, val text: String)
class EditorActivity : ComponentActivity() {
private lateinit var drafts: JotterList<Draft>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
drafts = Jotter.attach(this).list<Draft>("drafts")
}
}これだけで、プロセスキル後にユーザーがタスクへ戻ったとき drafts は自動的に復元される。flush もシステムが saved state を取るタイミングで自動で走るため、通常は明示的な保存操作は不要。
寿命単位はタスクではなく scene(UISceneSession、App Switcher のカードに対応)。API は io.github.kr9ly.jotter.Jotter(jotter-core の appleMain)が公開する 3 つ:
Jotter.autoAttach(json, config) / Jotter.store(scene) / Jotter.discardSessions(persistentIdentifiers)。
起動時に Jotter.autoAttach() を 1 回呼べば、以降接続されるすべての scene(呼び出し時点で既に接続済みの scene も含む)が自動的に結線される。
SceneDelegate 側で結線を呼ぶコードは不要になり、Swift 側に出るのは起動時の autoAttach とスワイプ破棄の掃除結線(discardSessions)の 2 つだけ。
ストアが要るところでは Jotter.store(scene) を呼ぶ。結線済みの scene ならそのまま既存のストアを返すだけで、autoAttach を使わず store(scene) だけを直接呼ぶ運用にも対応する(後述)。
// shared モジュール、iosMain — 結線の入口だけを Swift に開く
package com.example.app
import io.github.kr9ly.jotter.Jotter
import io.github.kr9ly.jotter.JotterList
import platform.UIKit.UIScene
object AppJotter {
// AppDelegate.didFinishLaunching から呼ぶ(結線 1)
fun start() {
Jotter.autoAttach()
}
// AppDelegate.didDiscardSceneSessions から呼ぶ(結線 2)
fun onSceneSessionsDiscarded(persistentIdentifiers: Set<String>) {
Jotter.discardSessions(persistentIdentifiers)
}
// ここから先は Kotlin 共有コードの世界 — 「使い方」の API がそのまま使える
fun drafts(scene: UIScene): JotterList<Draft> = Jotter.store(scene).list("drafts")
}補足: @JotterInternalApi の opt-in が要るのは、結線(autoAttach / store)を迂回してディレクトリを直指定で JotterStore を開く内部の開き口(JotterStore.open)を直接呼ぶときだけ。Jotter.store の戻り値を保持して list / map を使う通常の利用に opt-in は不要。
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
AppJotter.shared.start()
return true
}
func application(
_ application: UIApplication,
didDiscardSceneSessions sceneSessions: Set<UISceneSession>
) {
let identifiers = Set(sceneSessions.map { $0.persistentIdentifier })
AppJotter.shared.onSceneSessionsDiscarded(persistentIdentifiers: identifiers)
}
}SwiftUI アプリは既定では AppDelegate を持たないため、UIApplicationDelegateAdaptor で明示的に差し込む。autoAttach はライフサイクルの早い段階の結線点を必要としないため、SceneDelegate を新設する必要はない。
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}autoAttach は結線のタイミングをライブラリに委ねる糖衣にすぎない。attach のタイミングを自分で制御したい場合は autoAttach の呼び出しを省略し、好きなタイミングで Jotter.store(scene) を直接呼べばよい(store(scene) は未結線の scene を渡されるとその場で結線してから返す)。discardSessions の結線は autoAttach の有無に関わらず必須。
application(_:didDiscardSceneSessions:) からの discardSessions 呼び出しは、autoAttach / store と違いライブラリが自動フックできない唯一の結線点。
iOS にはライブラリ側から登録できるコールバック API がないため、この 1 点だけはアプリ側の実装が必須。
書き忘れてもデータが漏れ続けるわけではない — LRU 掃除(デフォルト 64 MiB 予算)が保険として働き、予算超過時に mtime の古いディレクトリから回収されるため、無制限にディスクを食い続けることはない。
json と config はプロセス内で最初に成立した結線(autoAttach または store の最初の呼び出し)にだけ反映される。2 回目以降に渡した json / config は黙って無視される。autoAttach と store のどちらを最初に呼ぶかは利用者の裁量だが、複数箇所で異なる設定を渡しても、勝つのは常に「プロセス内で最初に結線が成立した呼び出し」の設定だけ、という点は共通。
| Android | iOS | |
|---|---|---|
| 寿命単位 | タスク | scene |
| flush タイミング | saved state を取得するタイミング(onStop 相当)で自動 | scene の didEnterBackground 通知で自動 |
| 掃除の結線 | SavedStateProvider 経由でタスク終了をライブラリが自動検知 | didDiscardSceneSessions からの結線がアプリ側に必須 |
| バックアップ除外 | ホストアプリが backup rules で <filesDir>/jotter/ を除外する必要がある |
jotter 側が Application Support 配下の jotter ルートディレクトリを自動的にバックアップ対象から除外する(アプリ側の設定は不要) |
JotterConfig で変更可)、超過すると使われていないタスクのデータから LRU で消える<filesDir>/jotter/ を backup rules で除外すること(捨てていいデータがバックアップ容量を消費する。iOS は jotter 側で自動除外済み)android:process で分かれた Activity が同一タスクストアを共有するケース)設計判断の詳細(op-log・寿命委譲・スレッドモデル・既知の制約の全リスト)は docs/design.md を参照。
op-log エンジンを jotter-core(Kotlin Multiplatform)に切り出したメジャーリリース。詳細は CHANGELOG.md を参照。
Jotter.autoAttach() / Jotter.store(scene) による scene 寿命への結線)を新設し、iOS を全機能対応プラットフォームに追加した(上記「対応プラットフォーム」参照)1.x からのアップグレード: 依存座標をそのまま 2.0.0 に上げて再コンパイルするだけでよい。API・ディスクフォーマットとも互換なので、コード変更も既存タスクのデータ移行も不要。
Apache License 2.0 — 詳細は LICENSE を参照。
作業中データのための Kotlin Multiplatform ローカルストア(Android / iOS)。プロセスキルでは消えず、タスク(iOS では scene)を閉じると消える。
「大きすぎて Bundle(iOS では state restoration)に入らないが、飛んでも惜しくない」データ — 未送信の編集列、オフライン操作キュー、ページング済みリスト — のための置き場。実体は saved instance state と同じ寿命を持つファイルバックのリスト/マップで、保存も削除も明示的な操作が要らない。
永続データ(再インストールやタスク破棄をまたいで残すもの)には向かない。それは Room / DataStore の領分。 設定・フラグのような恒久的な key-value データも jotter の領分ではない。それは daybook の領分。
| ターゲット | 保証水準 |
|---|---|
| Android(minSdk 21+) | 全機能。これまでと同じ使い方(Jotter.attach → list / map)。CI は JVM 上の Robolectric ユニットテスト、実機スモーク(ProcessKillSmokeTest)はエミュレータでの手動実行 |
| iOS(iosArm64 / iosSimulatorArm64) | 全機能。Jotter.autoAttach() による scene 寿命への自動結線、自動 flush、discard 掃除、LRU、バックアップ除外まで提供。検証は GHA の iOS シミュレータ上での XCTest 回帰(ios-device-test.yml) |
| jvm | jotter-core のエンジンのみ。タスク寿命への結線は持たず、JotterStore を直接使う(ディレクトリを開く JotterStore.open だけ @JotterInternalApi opt-in が必要) |
KMP 共有モジュールを組む場合は commonMain に jotter-core(+ jotter-coroutines)を置き、Android のタスク寿命への結線だけ Android 側モジュールで jotter を足す形になる。 Android アプリ単体で使う分には jotter-core を直接意識する必要はない。jotter を依存に加えれば jotter-core は推移的に付いてくる。 iOS から使う場合は jotter-core を直接依存に加える(別モジュールは不要)。
同じ問題領域(作業中データの一時保存)の選択肢との比較。それぞれ得意分野が違うので、必要な軸で選ぶこと。
| onSaveInstanceState(Bundle) | SavedStateHandle | Room / DataStore を一時データに使う | jotter | |
|---|---|---|---|---|
| プロセスキル生存 | ○ | ○ | ○ | ○ |
| サイズ制限なし | ×(実質 1MB 弱) | ×(実質 1MB 弱) | ○ | ○ |
| タスク終了で自動削除 | ○ | △(ViewModel 寿命どまり) | ×(自前で削除コードが必要) | ○ |
| 明示的な保存操作が不要 | ×(onSaveInstanceState を実装) | △(key-value の set はいる) | ×(永続化は自前呼び出し) | ○ |
| 変更通知・Flow | × | ○(StateFlow) | ○ | ○(jotter-coroutines) |
| 恒久データ向き | × | × | ○ | × |
同カテゴリの中では SavedStateHandle が最も近い(変更通知を持つ点も含めて)が、サイズ制限とタスク終了時の掃除の要否で線引きが変わる。 この表は Android の代替手段との比較で、iOS 版には onSaveInstanceState / SavedStateHandle に相当する OS 標準 API がないため対象外。
Maven Central から取得できる(リポジトリに mavenCentral() が入っていればそのまま使える)。
Android / iOS 両対応の KMP 共有モジュールを組む場合の形。commonMain に jotter-core を置けば両 OS に効く。Android のタスク寿命への結線(Jotter.attach(activity))だけは Android 側モジュールに jotter を足す。
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.kr9ly:jotter-core:2.0.0")
implementation("io.github.kr9ly:jotter-coroutines:2.0.0") // Flow で受けたい場合のみ
}
androidMain.dependencies {
implementation("io.github.kr9ly:jotter:2.0.0") // Jotter.attach(activity) を使う場合
}
}
}iOS 側は jotter-core の appleMain に結線層(Jotter.autoAttach() / Jotter.store(scene))が同梱されているため、上記の commonMain 依存だけで足りる。追加の iOS 専用依存は不要。
KMP 構成を組まず、Android アプリだけで使う場合の形。以下は AGP 8 系までの一般的な消費側の書き方(kotlin-android プラグインを明示適用する)。AGP 9 のビルトイン Kotlin を使う構成では kotlin-android の適用は不要(jotter 自身のビルドはこの方式)。
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.0" // Kotlin プラグインと同じバージョンにする
}
dependencies {
implementation("io.github.kr9ly:jotter:2.0.0")
implementation("io.github.kr9ly:jotter-coroutines:2.0.0") // Flow で受けたい場合のみ
}KMP プロジェクトの iOS ターゲット(iosMain 等)に jotter-core を直接依存として追加する場合の形。別モジュールは不要。
kotlin {
sourceSets {
iosMain.dependencies {
implementation("io.github.kr9ly:jotter-core:2.0.0")
}
}
}保存する型(@Serializable なデータクラス)の直列化に kotlinx.serialization を使うため、いずれの形でも Kotlin plugin を合わせて適用すること(バージョンは Kotlin プラグインと同じにする)。
要件: Kotlin 2.0+ / Android は minSdk 21+
以下はリストへのハンドル(JotterList / JotterMap)を既に持っている前提の共通操作。JotterList / JotterMap / ChangeEvent はプラットフォーム共通の型なので、コード例は Android・iOS のどちらでもそのまま使える。
例中の jotter は attach で得たストア、drafts はそこから開いたハンドル。取得手順(attach)はプラットフォーム別 — 次節「attach — 寿命への結線」を参照。
レコードは安定 ID つき。読み取りは常にメモリから同期で返る。
drafts.add("d1", Draft("d1", "hello"))
drafts.update("d1", Draft("d1", "hello, world"))
drafts.remove("d1")
drafts.clear()
drafts.addAll(pages.associateBy { it.id }) // 一括 upsert(通知 1 回)
drafts.replaceAll(fresh.associateBy { it.id }) // 丸ごと差し替え(通知 1 回、途中の空状態なし)
drafts.snapshot() // List<Draft>(挿入順)
drafts.get("d1") // Draft?
if ("d1" in drafts) { ... }
drafts.size
drafts.isEmpty()update で値が変わらない場合(直列化結果が同一)は書き込みも通知も発生しない。 addAll / replaceAll も同じ差分検出を通るため、リフレッシュ結果が前回と同一なら通知ゼロで済む。
同じ名前をソート順つきで開くと、snapshot がソート済みで返る。
val byUpdated = jotter.list<Draft>("drafts", compareByDescending { it.updatedAt })
val byTitle = jotter.list<Draft>("drafts", compareBy { it.title })
byUpdated.snapshot() // 更新日時降順同じ名前を id → value の Map としても扱える。
val drafts = jotter.map<Draft>("drafts")
drafts["d1"] = Draft("d1", "hello") // upsert
val prev = drafts.put("d1", draft) // 直前の値が返る
drafts.remove("d1") // 取り除いた値が返る
drafts.clear()
drafts.putAll(items) // 一括 upsert(通知 1 回)
drafts.replaceAll(items) // 丸ごと差し替え(通知 1 回)
if ("d1" in drafts) { ... }
drafts.snapshot() // Map<String, Draft>(挿入順)同名の list と実体を共有するので、同じデータを List と Map の両方のイディオムで操作できる。
val listener = ChangeListener { render(drafts.snapshot()) }
drafts.addListener(listener)
// 不要になったら
drafts.removeListener(listener)ChangeListener は値を運ばない — 受け手が snapshot を引く。変更内容(何がどう変わったか)が必要なら ChangeEventListener で ChangeEvent(Added / Updated / Removed と id・値)を受け取れる。
import io.github.kr9ly.jotter.coroutines.events
import io.github.kr9ly.jotter.coroutines.snapshots
lifecycleScope.launch {
drafts.snapshots().collect { render(it) } // 現在値を即時 emit + 変更ごとに最新
}
lifecycleScope.launch {
drafts.events().collect { event -> // Flow<ChangeEvent<Draft>>
when (event) {
is ChangeEvent.Added -> ...
is ChangeEvent.Updated -> ...
is ChangeEvent.Removed -> ...
}
}
}snapshots() は conflate — 高頻度変更時は中間状態をスキップして最新だけが届くevents() は取りこぼしなし — 収集開始後の全イベントが順に流れるハンドル(JotterList / JotterMap)を得るための最初の一手は、寿命の単位が Android(タスク)と iOS(scene)で異なるため、プラットフォームごとに手順が分かれる。
Activity の onCreate(super の後)で attach し、名前つきリストを開く。
@Serializable
data class Draft(val id: String, val text: String)
class EditorActivity : ComponentActivity() {
private lateinit var drafts: JotterList<Draft>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
drafts = Jotter.attach(this).list<Draft>("drafts")
}
}これだけで、プロセスキル後にユーザーがタスクへ戻ったとき drafts は自動的に復元される。flush もシステムが saved state を取るタイミングで自動で走るため、通常は明示的な保存操作は不要。
寿命単位はタスクではなく scene(UISceneSession、App Switcher のカードに対応)。API は io.github.kr9ly.jotter.Jotter(jotter-core の appleMain)が公開する 3 つ:
Jotter.autoAttach(json, config) / Jotter.store(scene) / Jotter.discardSessions(persistentIdentifiers)。
起動時に Jotter.autoAttach() を 1 回呼べば、以降接続されるすべての scene(呼び出し時点で既に接続済みの scene も含む)が自動的に結線される。
SceneDelegate 側で結線を呼ぶコードは不要になり、Swift 側に出るのは起動時の autoAttach とスワイプ破棄の掃除結線(discardSessions)の 2 つだけ。
ストアが要るところでは Jotter.store(scene) を呼ぶ。結線済みの scene ならそのまま既存のストアを返すだけで、autoAttach を使わず store(scene) だけを直接呼ぶ運用にも対応する(後述)。
// shared モジュール、iosMain — 結線の入口だけを Swift に開く
package com.example.app
import io.github.kr9ly.jotter.Jotter
import io.github.kr9ly.jotter.JotterList
import platform.UIKit.UIScene
object AppJotter {
// AppDelegate.didFinishLaunching から呼ぶ(結線 1)
fun start() {
Jotter.autoAttach()
}
// AppDelegate.didDiscardSceneSessions から呼ぶ(結線 2)
fun onSceneSessionsDiscarded(persistentIdentifiers: Set<String>) {
Jotter.discardSessions(persistentIdentifiers)
}
// ここから先は Kotlin 共有コードの世界 — 「使い方」の API がそのまま使える
fun drafts(scene: UIScene): JotterList<Draft> = Jotter.store(scene).list("drafts")
}補足: @JotterInternalApi の opt-in が要るのは、結線(autoAttach / store)を迂回してディレクトリを直指定で JotterStore を開く内部の開き口(JotterStore.open)を直接呼ぶときだけ。Jotter.store の戻り値を保持して list / map を使う通常の利用に opt-in は不要。
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
AppJotter.shared.start()
return true
}
func application(
_ application: UIApplication,
didDiscardSceneSessions sceneSessions: Set<UISceneSession>
) {
let identifiers = Set(sceneSessions.map { $0.persistentIdentifier })
AppJotter.shared.onSceneSessionsDiscarded(persistentIdentifiers: identifiers)
}
}SwiftUI アプリは既定では AppDelegate を持たないため、UIApplicationDelegateAdaptor で明示的に差し込む。autoAttach はライフサイクルの早い段階の結線点を必要としないため、SceneDelegate を新設する必要はない。
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}autoAttach は結線のタイミングをライブラリに委ねる糖衣にすぎない。attach のタイミングを自分で制御したい場合は autoAttach の呼び出しを省略し、好きなタイミングで Jotter.store(scene) を直接呼べばよい(store(scene) は未結線の scene を渡されるとその場で結線してから返す)。discardSessions の結線は autoAttach の有無に関わらず必須。
application(_:didDiscardSceneSessions:) からの discardSessions 呼び出しは、autoAttach / store と違いライブラリが自動フックできない唯一の結線点。
iOS にはライブラリ側から登録できるコールバック API がないため、この 1 点だけはアプリ側の実装が必須。
書き忘れてもデータが漏れ続けるわけではない — LRU 掃除(デフォルト 64 MiB 予算)が保険として働き、予算超過時に mtime の古いディレクトリから回収されるため、無制限にディスクを食い続けることはない。
json と config はプロセス内で最初に成立した結線(autoAttach または store の最初の呼び出し)にだけ反映される。2 回目以降に渡した json / config は黙って無視される。autoAttach と store のどちらを最初に呼ぶかは利用者の裁量だが、複数箇所で異なる設定を渡しても、勝つのは常に「プロセス内で最初に結線が成立した呼び出し」の設定だけ、という点は共通。
| Android | iOS | |
|---|---|---|
| 寿命単位 | タスク | scene |
| flush タイミング | saved state を取得するタイミング(onStop 相当)で自動 | scene の didEnterBackground 通知で自動 |
| 掃除の結線 | SavedStateProvider 経由でタスク終了をライブラリが自動検知 | didDiscardSceneSessions からの結線がアプリ側に必須 |
| バックアップ除外 | ホストアプリが backup rules で <filesDir>/jotter/ を除外する必要がある |
jotter 側が Application Support 配下の jotter ルートディレクトリを自動的にバックアップ対象から除外する(アプリ側の設定は不要) |
JotterConfig で変更可)、超過すると使われていないタスクのデータから LRU で消える<filesDir>/jotter/ を backup rules で除外すること(捨てていいデータがバックアップ容量を消費する。iOS は jotter 側で自動除外済み)android:process で分かれた Activity が同一タスクストアを共有するケース)設計判断の詳細(op-log・寿命委譲・スレッドモデル・既知の制約の全リスト)は docs/design.md を参照。
op-log エンジンを jotter-core(Kotlin Multiplatform)に切り出したメジャーリリース。詳細は CHANGELOG.md を参照。
Jotter.autoAttach() / Jotter.store(scene) による scene 寿命への結線)を新設し、iOS を全機能対応プラットフォームに追加した(上記「対応プラットフォーム」参照)1.x からのアップグレード: 依存座標をそのまま 2.0.0 に上げて再コンパイルするだけでよい。API・ディスクフォーマットとも互換なので、コード変更も既存タスクのデータ移行も不要。
Apache License 2.0 — 詳細は LICENSE を参照。