算法艺术创作
algorithmic-art
通过代码和可调参数创作生成艺术与交互式视觉效果。
mobile-offline-first【设计与创意】Use this skill when the user says 'offline-first', 'offline mode', 'offline sync', 'local storage', 'offline cache', 'sync strategy', 'conflict resolution', 'offline queue', 'mobile offline', 'local database'. Design offline-capable mobile apps with local storage, sync strategies, conflict resolution, and connectivity detection. Do NOT use for: web o…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Guide for building mobile apps with offline-first architecture: local-first data, background sync, conflict resolution, and connectivity-aware UX.
Phrases: "offline-first", "offline mode", "offline sync", "local storage", "offline cache", "sync strategy", "conflict resolution", "offline queue", "mobile offline", "local database"
Offline architecture: local DB schema, repository pattern with cache-first strategy, sync engine with queue/retry, conflict resolution rules, connectivity detection.
<offline-first>
<local-db>{schema, repository, cache-first reads}</local-db>
<sync>{triggers, queue, replay, idempotency}</sync>
<conflicts>{strategy per entity, manual resolution}</conflicts>
<connectivity>{detection, UI states}</connectivity>
</offline-first>
No preamble. No postamble. No explanations.
8000 tokens
What kind of data?
├── Reference data (product catalog, settings) → Cache-first, pull-only
│ └── No conflict risk. Just stale data.
├── User-generated (notes, photos, orders) → Local-first with sync queue
│ └── Conflict possible. Need idempotency + resolution.
├── Collaborative (shared docs, lists, boards) → CRDT or OT
│ └── Concurrent edits likely. Need merge strategy.
└── Financial/legal (transactions, signatures) → Optimistic local, server authority
└── Conflicts must be manually resolved. No auto-merge.
Cross-platform need?
├── Single platform
│ ├── Android → Room (compile-time SQL, Flow, migrations)
│ └── iOS → CoreData (SwiftUI integration, iCloud sync)
├── KMP (Kotlin Multiplatform) → SQLDelight (type-safe, cross-platform)
├── Hybrid/React Native → WatermelonDB (SQLite-based, lazy loading)
└── Simple key-value → MMKV (fast, no schema needed)
How fresh does data need to be?
├── Seconds (chat, live collab) → WebSocket real-time push
├── Minutes (feeds, notifications) → Silent push + foreground sync
├── Hours (reference, catalog) → Periodic background sync (WorkManager / BGTaskScheduler)
└── On demand (user action required) → Pull-to-refresh only
The local database is the single source of truth. All reads go to local DB first. All writes go to local DB first, then sync to server. The network is a sync mechanism, not a data source.
Three layers:
Read flow: return cached → check staleness TTL → if stale+online: fetch, update local DB, notify → if offline: return cached with staleness indicator. Write flow: validate → write to local DB → enqueue sync op → if online, trigger immediate sync → UI reacts to local DB change.
Persistent queue in local DB. Each operation: id, entity type, entity ID, action, payload, idempotency key, created timestamp, retry count, last error. FIFO replay with exponential backoff.
Android: ConnectivityManager.NetworkCallback. iOS: NWPathMonitor. UI: persistent offline banner, sync status indicator, disable mutation buttons with tooltip.
| Strategy | Use Case | Complexity | Data Loss Risk |
|---|---|---|---|
| Last-write-wins | Independent entities, timestamps | Low | Medium |
| Timestamp authority | Single-user-per-resource | Low | Low |
| CRDT | Concurrent edits, lists, counters | High | None |
| Three-way merge | Collaborative documents | Medium | Low |
| Manual | Financial, legal, medical | High | None (user decides) |
// OrderRepository.kt
class OrderRepository(
private val dao: OrderDao,
private val api: OrderApi,
private val syncQueue: SyncQueue,
private val connectivity: ConnectivityMonitor
) {
fun getOrders(): Flow<List<Order>> = dao.observeAll()
suspend fun refreshOrders() {
if (!connectivity.isOnline()) return
try {
val remote = api.getOrders(updatedSince = lastSyncTimestamp)
dao.upsertAll(remote.map { it.toEntity() })
lastSyncTimestamp = System.currentTimeMillis()
} catch (e: Exception) {
// Silent — cached data still available
}
}
suspend fun createOrder(order: Order) {
val id = UUID.randomUUID().toString()
val localOrder = order.copy(id = id, syncStatus = SyncStatus.PENDING)
dao.insert(localOrder)
syncQueue.enqueue(
SyncOperation(
entityType = "order",
entityId = id,
action = SyncAction.CREATE,
payload = Json.encodeToString(order),
idempotencyKey = generateIdempotencyKey(order)
)
)
syncQueue.processIfOnline()
}
}
// SyncEngine.kt
class SyncEngine(
private val queue: SyncQueue,
private val api: OrderApi,
private val connectivity: ConnectivityMonitor
) {
suspend fun processQueue() {
if (!connectivity.isOnline()) return
val operations = queue.getAllPending()
for (op in operations) {
try {
when (op.action) {
SyncAction.CREATE -> api.createOrder(op.payload, op.idempotencyKey)
SyncAction.UPDATE -> api.updateOrder(op.entityId, op.payload, op.idempotencyKey)
SyncAction.DELETE -> api.deleteOrder(op.entityId, op.idempotencyKey)
}
queue.remove(op.id)
} catch (e: ConflictException) {
resolveConflict(op, e)
} catch (e: ServerException) {
if (op.retryCount >= MAX_RETRIES) {
queue.markFailed(op.id)
} else {
queue.incrementRetry(op.id)
delay(calculateBackoff(op.retryCount))
}
}
}
}
private fun calculateBackoff(retryCount: Int): Long {
val delays = listOf(1000L, 2000L, 5000L, 15000L, 30000L, 60000L)
return delays.getOrElse(retryCount) { 60000L }
}
}
class SyncQueue {
final Database db;
Future<void> enqueue(SyncOperation op) async {
await db.then((db) => db.insert('sync_queue', {
'id': op.id,
'entity_type': op.entityType,
'entity_id': op.entityId,
'action': op.action.index,
'payload': jsonEncode(op.payload),
'idempotency_key': op.idempotencyKey,
'created_at': DateTime.now().toIso8601String(),
'retry_count': 0,
'status': 'pending',
}));
}
Future<void> process() async {
final ops = await db.then((db) => db.query('sync_queue',
where: 'status = ?', whereArgs: ['pending'],
orderBy: 'created_at ASC'));
for (final op in ops) {
try {
await _execute(op);
await db.then((db) => db.delete('sync_queue',
where: 'id = ?', whereArgs: [op['id']]));
} on ServerException catch (e) {
if (e.statusCode == 409) {
await _handleConflict(op);
} else if ((op['retry_count'] as int) >= 5) {
await db.then((db) => db.update('sync_queue',
{'status': 'failed', 'last_error': e.message},
where: 'id = ?', whereArgs: [op['id']]));
} else {
await db.then((db) => db.update('sync_queue',
{'retry_count': (op['retry_count'] as int) + 1},
where: 'id = ?', whereArgs: [op['id']]));
}
}
}
}
String generateIdempotencyKey(Map<String, dynamic> payload) {
final content = jsonEncode(payload);
return sha256.convert(utf8.encode(content)).toString();
}
}
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
syncEngine.processQueue()
if (syncEngine.hasMorePages()) Result.retry()
else Result.success()
} catch (e: Exception) {
Result.retry()
}
}
companion object {
fun schedule(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
15, TimeUnit.MINUTES
).setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
1, TimeUnit.MINUTES
)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
"offline-sync",
ExistingPeriodicWorkPolicy.KEEP,
syncRequest
)
}
}
}
import BackgroundTasks
class BackgroundSyncManager {
static let shared = BackgroundSyncManager()
private let taskID = "com.example.app.sync"
func register() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskID, using: nil) { task in
self.handleSync(task: task as! BGAppRefreshTask)
}
}
func schedule() {
let request = BGAppRefreshTaskRequest(identifier: taskID)
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min
try? BGTaskScheduler.shared.submit(request)
}
private func handleSync(task: BGAppRefreshTask) {
schedule() // Schedule next refresh
let operation = SyncOperation(context: persistentContainer.viewContext)
operation.onComplete = {
task.setTaskCompleted(success: true)
}
operation.onFail = {
task.setTaskCompleted(success: false)
}
task.expirationHandler = {
operation.cancel()
}
operation.start()
}
}
import 'package:connectivity_plus/connectivity_plus.dart';
class ConnectivityService {
final Connectivity _connectivity = Connectivity();
bool _isOnline = true;
Stream<bool> get connectivityStream => _connectivity.onConnectivityChanged
.map((result) => result != ConnectivityResult.none);
Future<bool> checkOnline() async {
final result = await _connectivity.checkConnectivity();
return result != ConnectivityResult.none;
}
void startMonitoring(void Function(bool) onChanged) {
_connectivity.onConnectivityChanged.listen((result) {
final online = result != ConnectivityResult.none;
if (online && !_isOnline) {
// Came back online — trigger sync
SyncQueue.instance.process();
}
_isOnline = online;
onChanged(online);
});
}
}
// iOS connectivity
import Network
class NetworkMonitor {
static let shared = NetworkMonitor()
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor")
var isConnected: Bool {
monitor.currentPath.status == .satisfied
}
var isExpensive: Bool {
monitor.currentPath.isExpensive
} // cellular vs WiFi
func start() {
monitor.pathUpdateHandler = { path in
NotificationCenter.default.post(
name: .connectivityChanged,
object: path.status == .satisfied
)
}
monitor.start(queue: queue)
}
}
// Flutter — offline indicator
class ConnectivityBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StreamBuilder<bool>(
stream: ConnectivityService().connectivityStream,
builder: (context, snapshot) {
final online = snapshot.data ?? true;
return Column(
children: [
if (!online)
Container(
width: double.infinity,
color: Colors.orange.shade800,
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 16),
child: Text('You are offline. Changes will sync when connected.',
style: TextStyle(color: Colors.white, fontSize: 12)),
),
// Rest of app
],
);
},
);
}
}
// Android — offline state in ViewModel
class OrderViewModel(private val repo: OrderRepository) : ViewModel() {
val orders: StateFlow<List<Order>> = repo.getOrders()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val syncStatus: StateFlow<SyncStatus> = repo.syncStatus
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SyncStatus.Synced)
val connectivityState: StateFlow<Boolean> = repo.connectivityMonitor
.observeConnectivity()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
}
// Grow-only Counter CRDT
class GCounter {
private val counts = mutableMapOf<String, Int>()
fun increment(nodeId: String) {
counts[nodeId] = (counts[nodeId] ?: 0) + 1
}
fun get(): Int = counts.values.sum()
fun merge(other: GCounter) {
other.counts.forEach { (node, count) ->
counts[node] = maxOf(counts[node] ?: 0, count)
}
}
}
data class Document(
val id: String,
val title: String,
val content: String,
val version: Int
)
fun threeWayMerge(base: Document, local: Document, remote: Document): Document {
return Document(
id = base.id,
title = if (local.title != base.title) local.title else remote.title,
content = if (local.content != base.content) local.content else remote.content,
version = maxOf(local.version, remote.version) + 1
)
}
updatedSince timestamps to pull changed records onlyreferences/conflict-resolution.md — Conflict Resolution for Offline-Firstreferences/local-storage.md — Local Storagereferences/mobile-database.md — Mobile Local Databasereferences/offline-first-architecture.md — Offline-First Architecturereferences/offline-sync.md — Offline Syncreferences/sync-strategies.md — Sync StrategiesAfter offline-first setup, hand off to:
mobile/universal/networking — Sync transport, API design for delta syncmobile/universal/storage — Local DB selection, schema designmobile/universal/testing — Offline scenario testing, sync testingmobile/universal/performance — Sync performance, DB query optimizationmobile/universal/push-notifications — Silent push as sync triggermobile/android — WorkManager, Room specificsmobile/ios — CoreData, BGTaskScheduler specifics