jpskill.com
🛠️ 開発・MCP コミュニティ

offline-first

オフライン環境でも快適に動作するアプリ開発のため、データ同期や競合解決、キャッシュ管理、ネットワーク接続監視など、様々な技術を駆使して最適なアーキテクチャを構築するSkill。

📜 元の英語説明(参考)

Offline-first architecture patterns - NetworkBoundResource, sync strategies, conflict resolution, cache invalidation, and connectivity monitoring.

🇯🇵 日本人クリエイター向け解説

一言でいうと

オフライン環境でも快適に動作するアプリ開発のため、データ同期や競合解決、キャッシュ管理、ネットワーク接続監視など、様々な技術を駆使して最適なアーキテクチャを構築するSkill。

※ jpskill.com 編集部が日本のビジネス現場向けに補足した解説です。Skill本体の挙動とは独立した参考情報です。

⚡ おすすめ: コマンド1行でインストール(60秒)

下記のコマンドをコピーしてターミナル(Mac/Linux)または PowerShell(Windows)に貼り付けてください。 ダウンロード → 解凍 → 配置まで全自動。

🍎 Mac / 🐧 Linux
mkdir -p ~/.claude/skills && cd ~/.claude/skills && curl -L -o offline-first.zip https://jpskill.com/download/16436.zip && unzip -o offline-first.zip && rm offline-first.zip
🪟 Windows (PowerShell)
$d = "$env:USERPROFILE\.claude\skills"; ni -Force -ItemType Directory $d | Out-Null; iwr https://jpskill.com/download/16436.zip -OutFile "$d\offline-first.zip"; Expand-Archive "$d\offline-first.zip" -DestinationPath $d -Force; ri "$d\offline-first.zip"

完了後、Claude Code を再起動 → 普通に「動画プロンプト作って」のように話しかけるだけで自動発動します。

💾 手動でダウンロードしたい(コマンドが難しい人向け)
  1. 1. 下の青いボタンを押して offline-first.zip をダウンロード
  2. 2. ZIPファイルをダブルクリックで解凍 → offline-first フォルダができる
  3. 3. そのフォルダを C:\Users\あなたの名前\.claude\skills\(Win)または ~/.claude/skills/(Mac)へ移動
  4. 4. Claude Code を再起動

⚠️ ダウンロード・利用は自己責任でお願いします。当サイトは内容・動作・安全性について責任を負いません。

🎯 このSkillでできること

下記の説明文を読むと、このSkillがあなたに何をしてくれるかが分かります。Claudeにこの分野の依頼をすると、自動で発動します。

📦 インストール方法 (3ステップ)

  1. 1. 上の「ダウンロード」ボタンを押して .skill ファイルを取得
  2. 2. ファイル名の拡張子を .skill から .zip に変えて展開(macは自動展開可)
  3. 3. 展開してできたフォルダを、ホームフォルダの .claude/skills/ に置く
    • · macOS / Linux: ~/.claude/skills/
    • · Windows: %USERPROFILE%\.claude\skills\

Claude Code を再起動すれば完了。「このSkillを使って…」と話しかけなくても、関連する依頼で自動的に呼び出されます。

詳しい使い方ガイドを見る →
最終更新
2026-05-18
取得日時
2026-05-18
同梱ファイル
1

📖 Skill本文(日本語訳)

※ 原文(英語/中国語)を Gemini で日本語化したものです。Claude 自身は原文を読みます。誤訳がある場合は原文をご確認ください。

Offline-First Architecture Patterns

NetworkBoundResource Pattern

キャッシュとネットワークのデータソースを調整する中心的な抽象化です。

inline fun <ResultType, RequestType> networkBoundResource(
    crossinline query: () -> Flow<ResultType>,
    crossinline fetch: suspend () -> RequestType,
    crossinline saveFetchResult: suspend (RequestType) -> Unit,
    crossinline shouldFetch: (ResultType) -> Boolean = { true },
    crossinline onFetchFailed: (Throwable) -> Unit = { }
): Flow<Resource<ResultType>> = flow {
    emit(Resource.Loading())

    val cachedData = query().first()

    if (shouldFetch(cachedData)) {
        emit(Resource.Loading(cachedData))
        try {
            val fetchedData = fetch()
            saveFetchResult(fetchedData)
        } catch (e: Exception) {
            onFetchFailed(e)
        }
    }

    emitAll(query().map { Resource.Success(it) })
}

Resource Wrapper

sealed class Resource<out T> {
    data class Success<T>(val data: T) : Resource<T>()
    data class Loading<T>(val data: T? = null) : Resource<T>()
    data class Error<T>(val message: String, val data: T? = null) : Resource<T>()
}

Repository での使用例

class ArticleRepository(
    private val api: ArticleApi,
    private val dao: ArticleDao,
    private val cachePolicy: CachePolicy
) {
    fun getArticles(): Flow<Resource<List<Article>>> = networkBoundResource(
        query = { dao.observeAll() },
        fetch = { api.getArticles() },
        saveFetchResult = { articles ->
            dao.transaction {
                dao.deleteAll()
                dao.insertAll(articles.map { it.toEntity() })
            }
        },
        shouldFetch = { cachedArticles ->
            cachedArticles.isEmpty() || cachePolicy.isExpired("articles")
        }
    )
}

Cache-First vs Network-First Strategies

Cache-First (Offline-First のデフォルト)

fun getCacheFirst(): Flow<Resource<List<Item>>> = flow {
    emit(Resource.Loading())
    val cached = dao.getAll().first()
    if (cached.isNotEmpty()) {
        emit(Resource.Success(cached))
    }
    try {
        val fresh = api.fetchAll()
        dao.replaceAll(fresh.map { it.toEntity() })
    } catch (e: Exception) {
        if (cached.isEmpty()) emit(Resource.Error(e.message ?: "Network error"))
    }
    emitAll(dao.getAll().map { Resource.Success(it) })
}

Network-First (時間依存性の高いデータ向け)

fun getNetworkFirst(): Flow<Resource<List<Item>>> = flow {
    emit(Resource.Loading())
    try {
        val fresh = api.fetchAll()
        dao.replaceAll(fresh.map { it.toEntity() })
        emitAll(dao.getAll().map { Resource.Success(it) })
    } catch (e: Exception) {
        val cached = dao.getAll().first()
        if (cached.isNotEmpty()) {
            emit(Resource.Success(cached))
        } else {
            emit(Resource.Error(e.message ?: "No data available"))
        }
    }
}

TTL-Based Cache Invalidation

class CachePolicy(private val prefs: SharedPreferences) {

    fun isExpired(key: String, ttlMillis: Long = DEFAULT_TTL): Boolean {
        val lastFetch = prefs.getLong("cache_ts_$key", 0L)
        return System.currentTimeMillis() - lastFetch > ttlMillis
    }

    fun markFresh(key: String) {
        prefs.edit().putLong("cache_ts_$key", System.currentTimeMillis()).apply()
    }

    fun invalidate(key: String) {
        prefs.edit().remove("cache_ts_$key").apply()
    }

    companion object {
        const val DEFAULT_TTL = 15 * 60 * 1000L  // 15 minutes
        const val SHORT_TTL = 2 * 60 * 1000L     // 2 minutes
        const val LONG_TTL = 24 * 60 * 60 * 1000L // 24 hours
    }
}

Connectivity Monitoring

Android (ConnectivityManager)

class AndroidConnectivityMonitor(context: Context) : ConnectivityMonitor {

    private val connectivityManager =
        context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

    override val isConnected: Flow<Boolean> = callbackFlow {
        val callback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) { trySend(true) }
            override fun onLost(network: Network) { trySend(false) }
            override fun onUnavailable() { trySend(false) }
        }
        val request = NetworkRequest.Builder()
            .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
            .build()
        connectivityManager.registerNetworkCallback(request, callback)
        // Emit initial state
        trySend(connectivityManager.activeNetwork != null)
        awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
    }.distinctUntilChanged()
}

iOS (NWPathMonitor)

import Network

class ConnectivityMonitor: ObservableObject {
    private let monitor = NWPathMonitor()
    private let queue = DispatchQueue(label: "ConnectivityMonitor")

    @Published var isConnected = true

    init() {
        monitor.pathUpdateHandler = { [weak self] path in
            DispatchQueue.main.async {
                self?.isConnected = path.status == .satisfied
            }
        }
        monitor.start(queue: queue)
    }

    deinit { monitor.cancel() }
}

Sync Queue for Offline Writes

@Entity(tableName = "pending_operations")
data class PendingOperation(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val operationType: String,       // "CREATE", "UPDATE", "DELETE"
    val entityType: String,          // "article", "comment"
    val entityId: String,
    val payload: String,             // JSON-serialized body
    val createdAt: Long = System.currentTimeMillis(),
    val retryCount: Int = 0
)

class SyncQueue(
    private val pendingOpsDao: PendingOperationDao,
    private val connectivityMonitor: ConnectivityMonitor
) {
    suspend fun enqueue(operation: PendingOperation) {
        pendingOpsDao.insert(operation)
        if (connectivityMonitor.isCurr
📜 原文 SKILL.md(Claudeが読む英語/中国語)を展開

Offline-First Architecture Patterns

NetworkBoundResource Pattern

The core abstraction that coordinates cache and network data sources.

inline fun <ResultType, RequestType> networkBoundResource(
    crossinline query: () -> Flow<ResultType>,
    crossinline fetch: suspend () -> RequestType,
    crossinline saveFetchResult: suspend (RequestType) -> Unit,
    crossinline shouldFetch: (ResultType) -> Boolean = { true },
    crossinline onFetchFailed: (Throwable) -> Unit = { }
): Flow<Resource<ResultType>> = flow {
    emit(Resource.Loading())

    val cachedData = query().first()

    if (shouldFetch(cachedData)) {
        emit(Resource.Loading(cachedData))
        try {
            val fetchedData = fetch()
            saveFetchResult(fetchedData)
        } catch (e: Exception) {
            onFetchFailed(e)
        }
    }

    emitAll(query().map { Resource.Success(it) })
}

Resource Wrapper

sealed class Resource<out T> {
    data class Success<T>(val data: T) : Resource<T>()
    data class Loading<T>(val data: T? = null) : Resource<T>()
    data class Error<T>(val message: String, val data: T? = null) : Resource<T>()
}

Usage in Repository

class ArticleRepository(
    private val api: ArticleApi,
    private val dao: ArticleDao,
    private val cachePolicy: CachePolicy
) {
    fun getArticles(): Flow<Resource<List<Article>>> = networkBoundResource(
        query = { dao.observeAll() },
        fetch = { api.getArticles() },
        saveFetchResult = { articles ->
            dao.transaction {
                dao.deleteAll()
                dao.insertAll(articles.map { it.toEntity() })
            }
        },
        shouldFetch = { cachedArticles ->
            cachedArticles.isEmpty() || cachePolicy.isExpired("articles")
        }
    )
}

Cache-First vs Network-First Strategies

Cache-First (Default for Offline-First)

fun getCacheFirst(): Flow<Resource<List<Item>>> = flow {
    emit(Resource.Loading())
    val cached = dao.getAll().first()
    if (cached.isNotEmpty()) {
        emit(Resource.Success(cached))
    }
    try {
        val fresh = api.fetchAll()
        dao.replaceAll(fresh.map { it.toEntity() })
    } catch (e: Exception) {
        if (cached.isEmpty()) emit(Resource.Error(e.message ?: "Network error"))
    }
    emitAll(dao.getAll().map { Resource.Success(it) })
}

Network-First (For Time-Sensitive Data)

fun getNetworkFirst(): Flow<Resource<List<Item>>> = flow {
    emit(Resource.Loading())
    try {
        val fresh = api.fetchAll()
        dao.replaceAll(fresh.map { it.toEntity() })
        emitAll(dao.getAll().map { Resource.Success(it) })
    } catch (e: Exception) {
        val cached = dao.getAll().first()
        if (cached.isNotEmpty()) {
            emit(Resource.Success(cached))
        } else {
            emit(Resource.Error(e.message ?: "No data available"))
        }
    }
}

TTL-Based Cache Invalidation

class CachePolicy(private val prefs: SharedPreferences) {

    fun isExpired(key: String, ttlMillis: Long = DEFAULT_TTL): Boolean {
        val lastFetch = prefs.getLong("cache_ts_$key", 0L)
        return System.currentTimeMillis() - lastFetch > ttlMillis
    }

    fun markFresh(key: String) {
        prefs.edit().putLong("cache_ts_$key", System.currentTimeMillis()).apply()
    }

    fun invalidate(key: String) {
        prefs.edit().remove("cache_ts_$key").apply()
    }

    companion object {
        const val DEFAULT_TTL = 15 * 60 * 1000L  // 15 minutes
        const val SHORT_TTL = 2 * 60 * 1000L     // 2 minutes
        const val LONG_TTL = 24 * 60 * 60 * 1000L // 24 hours
    }
}

Connectivity Monitoring

Android (ConnectivityManager)

class AndroidConnectivityMonitor(context: Context) : ConnectivityMonitor {

    private val connectivityManager =
        context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

    override val isConnected: Flow<Boolean> = callbackFlow {
        val callback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) { trySend(true) }
            override fun onLost(network: Network) { trySend(false) }
            override fun onUnavailable() { trySend(false) }
        }
        val request = NetworkRequest.Builder()
            .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
            .build()
        connectivityManager.registerNetworkCallback(request, callback)
        // Emit initial state
        trySend(connectivityManager.activeNetwork != null)
        awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
    }.distinctUntilChanged()
}

iOS (NWPathMonitor)

import Network

class ConnectivityMonitor: ObservableObject {
    private let monitor = NWPathMonitor()
    private let queue = DispatchQueue(label: "ConnectivityMonitor")

    @Published var isConnected = true

    init() {
        monitor.pathUpdateHandler = { [weak self] path in
            DispatchQueue.main.async {
                self?.isConnected = path.status == .satisfied
            }
        }
        monitor.start(queue: queue)
    }

    deinit { monitor.cancel() }
}

Sync Queue for Offline Writes

@Entity(tableName = "pending_operations")
data class PendingOperation(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val operationType: String,       // "CREATE", "UPDATE", "DELETE"
    val entityType: String,          // "article", "comment"
    val entityId: String,
    val payload: String,             // JSON-serialized body
    val createdAt: Long = System.currentTimeMillis(),
    val retryCount: Int = 0
)

class SyncQueue(
    private val pendingOpsDao: PendingOperationDao,
    private val connectivityMonitor: ConnectivityMonitor
) {
    suspend fun enqueue(operation: PendingOperation) {
        pendingOpsDao.insert(operation)
        if (connectivityMonitor.isCurrentlyConnected()) {
            processQueue()
        }
    }

    suspend fun processQueue() {
        val pending = pendingOpsDao.getAllPending()
        for (op in pending) {
            try {
                executeSyncOperation(op)
                pendingOpsDao.delete(op)
            } catch (e: Exception) {
                if (op.retryCount >= MAX_RETRIES) {
                    pendingOpsDao.delete(op)
                } else {
                    pendingOpsDao.update(op.copy(retryCount = op.retryCount + 1))
                }
            }
        }
    }
}

Conflict Resolution Strategies

Last-Write-Wins

suspend fun resolveConflictLastWriteWins(
    local: SyncEntity,
    remote: SyncEntity
): SyncEntity {
    return if (local.updatedAt >= remote.updatedAt) local else remote
}

Field-Level Merge

suspend fun resolveConflictMerge(
    base: Article,
    local: Article,
    remote: Article
): Article {
    return Article(
        id = base.id,
        title = if (local.title != base.title) local.title else remote.title,
        body = if (local.body != base.body) local.body else remote.body,
        updatedAt = maxOf(local.updatedAt, remote.updatedAt)
    )
}

Retry with Exponential Backoff

suspend fun <T> retryWithBackoff(
    maxRetries: Int = 3,
    initialDelay: Long = 1000L,
    maxDelay: Long = 30_000L,
    factor: Double = 2.0,
    block: suspend () -> T
): T {
    var currentDelay = initialDelay
    repeat(maxRetries - 1) { attempt ->
        try {
            return block()
        } catch (e: Exception) {
            delay(currentDelay)
            currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
        }
    }
    return block() // final attempt, let exception propagate
}

Android WorkManager for Background Sync

class SyncWorker(
    context: Context,
    params: WorkerParameters,
    private val syncQueue: SyncQueue
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        return try {
            syncQueue.processQueue()
            Result.success()
        } catch (e: Exception) {
            if (runAttemptCount < 3) Result.retry() else Result.failure()
        }
    }
}

// Schedule periodic sync
fun scheduleSyncWork(workManager: WorkManager) {
    val constraints = Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .build()

    val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
        .setConstraints(constraints)
        .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
        .build()

    workManager.enqueueUniquePeriodicWork(
        "sync_work",
        ExistingPeriodicWorkPolicy.KEEP,
        syncRequest
    )
}

iOS BGTaskScheduler Equivalent

import BackgroundTasks

func registerBackgroundSync() {
    BGTaskScheduler.shared.register(
        forTaskWithIdentifier: "com.example.sync",
        using: nil
    ) { task in
        handleSync(task: task as! BGProcessingTask)
    }
}

func scheduleSync() {
    let request = BGProcessingTaskRequest(identifier: "com.example.sync")
    request.requiresNetworkConnectivity = true
    request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
    try? BGTaskScheduler.shared.submit(request)
}

func handleSync(task: BGProcessingTask) {
    let syncTask = Task {
        do {
            try await SyncService.shared.processQueue()
            task.setTaskCompleted(success: true)
        } catch {
            task.setTaskCompleted(success: false)
        }
    }
    task.expirationHandler = { syncTask.cancel() }
}

Best Practices

  • Default to cache-first; use network-first only for data where staleness causes real harm.
  • Always show cached data immediately, then update when the network responds.
  • Persist pending writes in a local table so they survive app restarts.
  • Use structured concurrency to cancel in-flight network requests when the user navigates away.
  • Set reasonable TTLs per data type: user profiles (long), feeds (short), real-time data (none).
  • Log sync failures and expose retry controls in the UI for transparency.
  • Test offline scenarios by toggling airplane mode and verifying queue processing.