Template
chore: 初始化脚手架 — sunny_mochi 企业级 Flutter 模板
Core 基础设施:Flavor 三包体系、Drift+SQLCipher 加密数据库 7 拦截器 Dio 网络栈、CrashReporter 崩溃日志、Sealed Failure 错误体系 5 色板主题系统、Auth 认证骨架(Clean Architecture)、Dev Panel、Mock Adapter 通过验证:flutter analyze(0 errors)、flutter test(全绿)、staging APK 74.8MB
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
// ignore_for_file: one_member_abstracts — Strategy 接口,多种实现(ServerWins/LastWriteWins/Custom)
|
||||
|
||||
/// 冲突解决策略:服务端响应与本地版本不一致时如何决策。
|
||||
///
|
||||
/// v1.2 默认策略:`LastWriteWinsResolver`(按 serverUpdatedAt 比较时间戳)。
|
||||
/// 业务场景需要乐观锁 / 三方合并的,单独实现 `ConflictResolver` 子类。
|
||||
abstract class ConflictResolver<T> {
|
||||
/// 返回解决后的最终值(由调用方写回 DB)。
|
||||
T resolve({required T local, required T server});
|
||||
}
|
||||
|
||||
/// 默认实现:服务端权威 — 本地直接被服务端覆盖。
|
||||
/// 适合健康数据采集这种"服务端是 source-of-truth"的场景。
|
||||
class ServerWinsResolver<T> implements ConflictResolver<T> {
|
||||
const ServerWinsResolver();
|
||||
|
||||
@override
|
||||
T resolve({required T local, required T server}) => server;
|
||||
}
|
||||
|
||||
/// last-write-wins by timestamp。
|
||||
/// 调用方传入 timestamp 比较函数(avoid 包装 generic)。
|
||||
class LastWriteWinsResolver<T> implements ConflictResolver<T> {
|
||||
const LastWriteWinsResolver({
|
||||
required this.localTimestamp,
|
||||
required this.serverTimestamp,
|
||||
});
|
||||
|
||||
final DateTime Function(T value) localTimestamp;
|
||||
final DateTime Function(T value) serverTimestamp;
|
||||
|
||||
@override
|
||||
T resolve({required T local, required T server}) {
|
||||
return serverTimestamp(server).isAfter(localTimestamp(local))
|
||||
? server
|
||||
: local;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:sunny_mochi/core/observability/talker_setup.dart';
|
||||
import 'package:sunny_mochi/core/sync/sync_status.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'sync_service.g.dart';
|
||||
|
||||
/// 单条 dirty 记录的同步执行器。各业务模块实现此接口注册到 SyncService。
|
||||
abstract class SyncTask {
|
||||
/// 唯一标识(用于日志和去重)
|
||||
String get key;
|
||||
|
||||
/// 执行同步:成功返回 true,失败返回 false(保留 pending 等下轮)。
|
||||
/// 冲突时由实现方写入 conflictPayload 并返回 false。
|
||||
Future<bool> execute();
|
||||
}
|
||||
|
||||
/// Offline-First 后台同步调度器。
|
||||
///
|
||||
/// - 监听 [Connectivity] 网络状态变化,恢复联网时触发一轮同步
|
||||
/// - 业务层通过 [enqueue] 注册 SyncTask
|
||||
/// - 串行执行(避免并发污染服务端)
|
||||
///
|
||||
/// 详见 docs/flutter-architecture-design.md §五.21。
|
||||
class SyncService {
|
||||
SyncService({
|
||||
required Connectivity connectivity,
|
||||
}) : _connectivity = connectivity;
|
||||
|
||||
final Connectivity _connectivity;
|
||||
final List<SyncTask> _queue = [];
|
||||
|
||||
StreamSubscription<List<ConnectivityResult>>? _connSub;
|
||||
bool _running = false;
|
||||
|
||||
/// 启动后台监听。在 main.dart 完成 ProviderScope 初始化后调用。
|
||||
///
|
||||
/// **R-ROB-1 / 2026-05-10**:幂等 — 重复调用会先 cancel 旧 subscription,
|
||||
/// 防止 hot reload / Widget 重建时累积订阅造成 _drain 多次触发。
|
||||
Future<void> start() async {
|
||||
await _connSub?.cancel();
|
||||
_connSub = _connectivity.onConnectivityChanged.listen(_onConnectivity);
|
||||
final initial = await _connectivity.checkConnectivity();
|
||||
if (_isOnline(initial)) {
|
||||
unawaited(_drain());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _connSub?.cancel();
|
||||
_connSub = null;
|
||||
}
|
||||
|
||||
/// 业务层注册一个 dirty 任务(同步后由任务自身从队列移除)。
|
||||
void enqueue(SyncTask task) {
|
||||
_queue.add(task);
|
||||
appTalker.verbose('[Sync] enqueue ${task.key} (queue=${_queue.length})');
|
||||
unawaited(_drain());
|
||||
}
|
||||
|
||||
Future<void> _drain() async {
|
||||
if (_running || _queue.isEmpty) return;
|
||||
_running = true;
|
||||
try {
|
||||
while (_queue.isNotEmpty) {
|
||||
final task = _queue.removeAt(0);
|
||||
try {
|
||||
final ok = await task.execute();
|
||||
appTalker.info(
|
||||
'[Sync] ${task.key} ${ok ? "✓" : "✗ (status=${SyncStatus.conflict.name} 待解决)"}',
|
||||
);
|
||||
} on Object catch (e, st) {
|
||||
appTalker.warning('[Sync] ${task.key} 异常:$e', e, st);
|
||||
// 失败任务不重新入队(保持 dirty 在 DB 等下轮启动)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_running = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _onConnectivity(List<ConnectivityResult> results) {
|
||||
if (_isOnline(results)) {
|
||||
appTalker.info('[Sync] 检测到联网,触发 drain');
|
||||
unawaited(_drain());
|
||||
}
|
||||
}
|
||||
|
||||
bool _isOnline(List<ConnectivityResult> results) =>
|
||||
results.any((r) => r != ConnectivityResult.none);
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Connectivity connectivity(Ref ref) => Connectivity();
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
SyncService syncService(Ref ref) {
|
||||
final svc = SyncService(connectivity: ref.watch(connectivityProvider));
|
||||
ref.onDispose(() => unawaited(svc.dispose()));
|
||||
return svc;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'sync_service.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(connectivity)
|
||||
final connectivityProvider = ConnectivityProvider._();
|
||||
|
||||
final class ConnectivityProvider
|
||||
extends $FunctionalProvider<Connectivity, Connectivity, Connectivity>
|
||||
with $Provider<Connectivity> {
|
||||
ConnectivityProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'connectivityProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$connectivityHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<Connectivity> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
Connectivity create(Ref ref) {
|
||||
return connectivity(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(Connectivity value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<Connectivity>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$connectivityHash() => r'e66720f09edf1a8b09e450e1eaedd51da9443f0e';
|
||||
|
||||
@ProviderFor(syncService)
|
||||
final syncServiceProvider = SyncServiceProvider._();
|
||||
|
||||
final class SyncServiceProvider
|
||||
extends $FunctionalProvider<SyncService, SyncService, SyncService>
|
||||
with $Provider<SyncService> {
|
||||
SyncServiceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'syncServiceProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$syncServiceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<SyncService> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
SyncService create(Ref ref) {
|
||||
return syncService(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(SyncService value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<SyncService>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$syncServiceHash() => r'6a8c53aab166422156d95e8cd712592195946e99';
|
||||
@@ -0,0 +1,14 @@
|
||||
/// 单条业务记录的同步状态机。Drift 表用 SyncColumns mixin 4 字段约定记录此状态。
|
||||
enum SyncStatus {
|
||||
/// 本地新增/修改,待同步到服务端
|
||||
pending,
|
||||
|
||||
/// 正在同步中(避免并发重复发送)
|
||||
syncing,
|
||||
|
||||
/// 已成功同步到服务端
|
||||
synced,
|
||||
|
||||
/// 服务端拒绝/冲突,需要人工或策略解决
|
||||
conflict,
|
||||
}
|
||||
Reference in New Issue
Block a user