This commit is contained in:
2026-09-09 21:05:25 +08:00
parent 87ab06221c
commit c590f74b1e
14 changed files with 229 additions and 208 deletions
+47 -87
View File
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -18,6 +17,10 @@ const String kVariantN = 'n';
const String kKindSpecies = 'species';
const String kKindCombined = 'combined';
/// 识别档位偏好取值 s/n 同 [kVariantS]/[kVariantN]2026-09-11 多物种综合识别
/// 在 App 端下线:目录中 combined 条目直接忽略,历史 multi_* / 两维识别方式
/// 配置自动归并为对应档位,不再有 multi_* 取值)
/// 模型身份键:同一数据集不同档位是两个独立条目(下载/激活/记账互不影响)
typedef ModelKey = ({int datasetId, String variant});
@@ -101,19 +104,22 @@ class ModelBundle {
/// 档位标识符的「无子目录」形态,存量设备无需迁移),n 档存 `models/<datasetId>/n/`
/// 各目录含 model.tflite + labels.json + meta.jsonmeta 记录 {version, sha256}
/// 版本与摘要都未变化时跳过下载。记账键一律是 (datasetId, variant) 二元组。
/// 识别目标档位 [mode] 只是用户偏好(持久化):设置弹层卡片按钮面向该档位。
/// 识别档位 [mode] 只是用户偏好(持久化:s 高精度默认 / n 高性能,切换不直接
/// 热换运行中模型):作为各卡默认目标档与「下载完成自动启用」的判定依据
/// 2026-09-11 多物种综合识别 App 端下线——不再展示综合卡,目录中 combined
/// 条目直接忽略,历史 multi_* / 两维识别方式配置自动归并到对应档位)。
/// 实际运行由激活集驱动——每个数据集**至多一个档位**在使用:激活某档会自动停用
/// 同数据集另一档,不同数据集可用不同档位并行识别(2026-09-03 修订)。
/// 同数据集另一档,不同数据集可用不同档位并行识别(2026-09-03)。
/// 激活集是**会话态**2026-09-03 修订):每次进入视野页 [resetForSession] 清空、
/// 不跨会话持久化——识别需用户在模型清单手动启用(显式「下载」落地即启用目标档
/// 属于用户动作);上次崩溃/坏模型不会在下次打开时自动复现,用户总能看到仅预览
/// 界面并自行调整。
/// 目录**缓存优先**2026-09-03):最近一次成功拉取的 models 目录落盘
/// catalog.json[refresh] 开头先载入缓存并通知(弹层离线也有内容展示),网络
/// 成功后再以权威目录覆盖并落盘;清理/激活同步/待办检测只在网络成功(fetched
/// 后执行——缓存降级时不清文件不下载,离线首启不误删已下载模型。
/// 版本更新**手动制**2026-09-09):静默后台自动更新退场,切档/目录刷新仅
/// 检测目标档「未下载/待更新」置入 [modePending],由用户在横幅手动一键触发
/// 成功后再以权威目录覆盖并落盘;清理/激活同步只在网络成功(fetched)后执行
/// ——缓存降级时不清文件不下载,离线首启不误删已下载模型。
/// 模型更新**手动制**2026-09-09):无自动更新/待办横幅,新版本由用户在
/// 卡片上重新下载(使用中重下会原地生效)
class ModelManager extends ChangeNotifier {
static final ModelManager instance = ModelManager._();
@@ -135,10 +141,6 @@ class ModelManager extends ChangeNotifier {
String? _error;
Future<void>? _inFlight;
/// 目标档待办清单(2026-09-09):当前 [mode] 下「未下载」或「本地版本落后
/// 服务器」的模型条目,切档/目录刷新时重算,UI 出横幅由用户手动一键下载/更新
List<ModelCatalogItem> _modePending = const [];
/// 模型文件/激活集变更版本戳:下载完成或激活变化 +1,
/// UI 据此判断是否需要重建推理 worker(2026-09-01 自动更新引入)
int _revision = 0;
@@ -147,13 +149,10 @@ class ModelManager extends ChangeNotifier {
/// 服务器目录(弹层模型清单展示用;同一数据集可能 s/n 两行)
List<ModelCatalogItem> get catalog => _catalog;
/// 识别目标档位(默认 s 高精度):用户偏好,持久化;只决定卡片按钮与
/// 首次下载的自动激活档,不直接切换已在运行的模型(运行激活集)
/// 识别档位偏好(s 高精度 / n 高性能):作为各卡默认目标档
/// (不直接切换运行——运行激活集驱动
String get mode => _mode;
/// 目标档待办清单(未下载 / 本地版本落后),切档时检测、UI 出横幅手动触发
List<ModelCatalogItem> get modePending => _modePending;
bool isActive(int datasetId, String variant) =>
_active.contains((datasetId: datasetId, variant: variant));
@@ -205,33 +204,15 @@ class ModelManager extends ChangeNotifier {
return _models.map((m) => m.datasetName).join(',');
}
/// 切换识别目标档位:改偏好并持久化,同时**检测目标档待办**(未下载 /
/// 版本落后),结果置入 [modePending] 供 UI 横幅手动触发下载/更新
Future<void> setMode(String variant) async {
if (variant != kVariantS && variant != kVariantN) return;
if (_mode == variant) return;
_mode = variant;
/// 切换识别档位偏好(s 高精度 / n 高性能):只改默认目标档并持久化。
Future<void> setMode(String mode) async {
if (mode != kVariantS && mode != kVariantN) return;
if (_mode == mode) return;
_mode = mode;
await _saveMode();
await refreshModePending();
notifyListeners();
}
/// 重算目标档待办:未下载(无文件)或本地版本/sha 与服务器不符(有新版本)。
/// 下载中的条目跳过(落地后自然移出)
Future<void> refreshModePending() async {
final out = <ModelCatalogItem>[];
for (final item in _catalog.where((c) => c.variant == _mode)) {
final key = (datasetId: item.datasetId, variant: item.variant);
if (_progress.containsKey(key)) continue;
if (!await _hasFile(item)) {
out.add(item); // 未下载
continue;
}
if (!await _isLocal(item)) out.add(item); // 有新版本
}
_modePending = out;
}
/// 拉取目录并同步本地模型;并发调用共享同一进行中的刷新。
Future<void> refresh() {
if (_refreshing) return _inFlight ?? Future.value();
@@ -287,9 +268,9 @@ class ModelManager extends ChangeNotifier {
// 无缓存且未拉取成功(目录确为空):无从同步,等下次刷新
if (_catalog.isEmpty && !fetched) return;
// 只拉目录不下载;扫描本地已有模型文件供清单展示版本新旧都算已下载:
// 旧版本文件由 autoUpdate 静默补齐,无需用户看到「下载」按钮再下。
// 缓存目录同样扫描:离线重开也能正确标出已下载档位
// 只拉目录不下载;扫描本地已有模型文件供清单展示版本是否落后由
// 卡片对照目录版本提示「更新」)。缓存目录同样扫描:离线重开也能正确
// 标出已下载档位
final downloaded = <ModelKey>{};
for (final item in _catalog) {
if (await _hasFile(item)) {
@@ -300,8 +281,8 @@ class ModelManager extends ChangeNotifier {
..clear()
..addAll(downloaded);
// 清理/激活同步/自动更新只认网络拉到的权威目录:缓存降级时不清文件
// 不触发下载——离线首启不会误删已下载模型(2026-09-03)
// 清理/激活同步只认网络拉到的权威目录:缓存降级时不清文件——
// 离线首启不会误删已下载模型(2026-09-03)
if (!fetched) return;
await _prune(_catalog);
// 服务器已下线的 (数据集, 档位) 移出激活集
@@ -313,34 +294,15 @@ class ModelManager extends ChangeNotifier {
_models = await _loadBundles(_catalog);
_ready = true;
_error = null;
// 目标档待办重算(2026-09-09:自动更新退场,改为切档检测 + 手动触发——
// 静默后台下载不区分档位流量开销大,且会掩盖「待更新」状态)
await refreshModePending();
} catch (e) {
if (!_ready) _error = '模型目录拉取失败:$e';
// 已就绪过则保留旧目录/旧模型,不覆盖 error(下载级错误优先展示)
}
}
/// 本地是否已有匹配版本的文件(meta 版本+sha256 相符且文件存在)
Future<bool> _isLocal(ModelCatalogItem item) async {
final dir = await _modelDir(item.datasetId, item.variant);
try {
final meta = await _readMeta(dir);
final file = File('${dir.path}/model.tflite');
return meta != null &&
meta['version'] == item.version &&
meta['sha256'] == item.sha256 &&
await file.exists();
} catch (e) {
return false;
}
}
/// 按需下载:流式下载 + sha256 校验 + 落盘(labels/meta)。
/// [autoActivate](默认 true,用户显式下载)该数据集此前无任何档位在使用且
/// 本档为目录中唯一可选/匹配目标档时自动激活(下载即有识别);
/// autoUpdate 等后台补档传 false:只更新文件不改变激活状态。
/// 使用中的档位原地更新则字节生效(重建推理 worker)。失败重试一次并记录错误。
Future<bool> downloadModel(ModelCatalogItem item,
{bool autoActivate = true,
@@ -372,17 +334,15 @@ class ModelManager extends ChangeNotifier {
_models = await _loadBundles(_catalog);
} else if (autoActivate &&
!_active.any((k) => k.datasetId == item.datasetId)) {
// 用户显式下载且该数据集尚无档位在使用:自动激活目标档条目;
// 目录没有目标档(存量单档物种)时激活本条,保证下载即有识别
// 用户显式下载且该数据集尚无档位在使用:自动激活识别档位默认档
// 条目;目录没有默认档(存量单档物种)时激活本条,保证下载即有
// 识别。走 setActive 统一做同档覆盖互斥(同数据集至多一档运行)
final hasTarget = _catalog.any((c) =>
c.datasetId == item.datasetId && c.variant == _mode);
if (item.variant == _mode || !hasTarget) {
_active.add(key);
_revision++;
_models = await _loadBundles(_catalog);
c.datasetId == item.datasetId && c.variant == mode);
if (item.variant == mode || !hasTarget) {
await setActive(item.datasetId, item.variant, true);
}
}
unawaited(refreshModePending()); // 落地后移出待办横幅
notifyListeners();
return true;
}
@@ -576,7 +536,7 @@ class ModelManager extends ChangeNotifier {
await File('${root.path}/mode.json')
.writeAsString(jsonEncode({'mode': _mode}));
} catch (e) {
debugPrint('[ModelManager] 识别档位持久化失败: $e');
debugPrint('[ModelManager] 识别偏好持久化失败: $e');
}
}
@@ -588,10 +548,20 @@ class ModelManager extends ChangeNotifier {
final f = File('${root.path}/mode.json');
if (!await f.exists()) return;
final data = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
final m = data['mode'] as String? ?? kVariantS;
if (m == kVariantS || m == kVariantN) _mode = m;
final r = data['recognition'] as String?;
if (r != null) {
// 四选一时期(2026-09-11 定稿后、多物种下线前)的配置:后缀即档位
if (r.endsWith(kVariantN)) {
_mode = kVariantN;
} else if (r.endsWith(kVariantS)) {
_mode = kVariantS;
}
return;
}
// 更早 {mode 档位, source 单/多源} 两维结构:只看档位(multi 已下线)
if (data['mode'] == kVariantN) _mode = kVariantN;
} catch (e) {
debugPrint('[ModelManager] 识别档位读取失败: $e');
debugPrint('[ModelManager] 识别偏好读取失败: $e');
}
}
@@ -659,16 +629,6 @@ class ModelManager extends ChangeNotifier {
return bundles;
}
Future<Map<String, dynamic>?> _readMeta(Directory dir) async {
final f = File('${dir.path}/meta.json');
if (!await f.exists()) return null;
try {
return jsonDecode(await f.readAsString()) as Map<String, dynamic>;
} catch (_) {
return null;
}
}
Future<Directory> _rootDir() async {
if (_rootDirOverride != null) return _rootDirOverride();
final support = await getApplicationSupportDirectory();
@@ -688,8 +648,8 @@ class ModelManager extends ChangeNotifier {
return dir;
}
/// 目录条目对应的模型文件是否已存在本地(不校验版本:旧版本视为已下载,
/// 新版本由 autoUpdate 自动补齐
/// 目录条目对应的模型文件是否已存在本地(不校验版本:文件已存在即标已下载,
/// 是否落后由卡片端对照目录版本提示「更新」
Future<bool> _hasFile(ModelCatalogItem item) async {
final root = await _rootDir();
return File('${root.path}/${_subPath(item.datasetId, item.variant)}/model.tflite')