offline-first
オフライン環境でも快適に動作するアプリ開発のため、データ同期や競合解決、キャッシュ管理、ネットワーク接続監視など、様々な技術を駆使して最適なアーキテクチャを構築するSkill。
📜 元の英語説明(参考)
Offline-first architecture patterns - NetworkBoundResource, sync strategies, conflict resolution, cache invalidation, and connectivity monitoring.
🇯🇵 日本人クリエイター向け解説
オフライン環境でも快適に動作するアプリ開発のため、データ同期や競合解決、キャッシュ管理、ネットワーク接続監視など、様々な技術を駆使して最適なアーキテクチャを構築するSkill。
※ jpskill.com 編集部が日本のビジネス現場向けに補足した解説です。Skill本体の挙動とは独立した参考情報です。
下記のコマンドをコピーしてターミナル(Mac/Linux)または PowerShell(Windows)に貼り付けてください。 ダウンロード → 解凍 → 配置まで全自動。
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
$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. 下の青いボタンを押して
offline-first.zipをダウンロード - 2. ZIPファイルをダブルクリックで解凍 →
offline-firstフォルダができる - 3. そのフォルダを
C:\Users\あなたの名前\.claude\skills\(Win)または~/.claude/skills/(Mac)へ移動 - 4. Claude Code を再起動
⚠️ ダウンロード・利用は自己責任でお願いします。当サイトは内容・動作・安全性について責任を負いません。
🎯 このSkillでできること
下記の説明文を読むと、このSkillがあなたに何をしてくれるかが分かります。Claudeにこの分野の依頼をすると、自動で発動します。
📦 インストール方法 (3ステップ)
- 1. 上の「ダウンロード」ボタンを押して .skill ファイルを取得
- 2. ファイル名の拡張子を .skill から .zip に変えて展開(macは自動展開可)
- 3. 展開してできたフォルダを、ホームフォルダの
.claude/skills/に置く- · macOS / Linux:
~/.claude/skills/ - · Windows:
%USERPROFILE%\.claude\skills\
- · macOS / Linux:
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.