Files
2026-09-11 10:45:04 +08:00

660 lines
27 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart' show sha256;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import '../config/app_config.dart';
/// 识别档位标识:s = 高精度(@1280 精度优先,默认),n = 高性能(@704 速度优先)
const String kVariantS = 's';
const String kVariantN = 'n';
/// 模型类型(2026-09-09 综合训练):species = 单物种(缺省,老目录兼容),combined = 多物种综合
/// datasetId=0、datasetIds=覆盖物种列表;与其覆盖的单物种模型激活互斥)
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});
/// 模型目录条目(GET /api/v1/app/update 响应 data.models[])。
/// 服务器发布模型后随版本检查一同下发,App 按目录逐数据集下载/更新。
/// 双档位(2026-09-03):每数据集至多 2 条(s/n 各一),[variant] 标识档位。
class ModelCatalogItem {
final int datasetId;
final String datasetName;
final String variant;
final String kind; // species 单物种(缺省)| combined 多物种综合
final List<int> datasetIds; // combined:覆盖的数据集 id 列表
final String version;
final List<String> labels;
final int sizeBytes;
final String sha256;
final String downloadUrl;
final String coverUrl;
const ModelCatalogItem({
required this.datasetId,
required this.datasetName,
this.variant = kVariantS,
this.kind = kKindSpecies,
this.datasetIds = const [],
required this.version,
required this.labels,
required this.sizeBytes,
required this.sha256,
required this.downloadUrl,
this.coverUrl = '',
});
bool get isCombined => kind == kKindCombined;
factory ModelCatalogItem.fromJson(Map<String, dynamic> j) =>
ModelCatalogItem(
datasetId: (j['datasetId'] as num?)?.toInt() ?? 0,
datasetName: j['datasetName'] as String? ?? '',
// 旧目录无 variant 字段(2026-09-03 前发布的单档 s)→ 归为 s
variant: j['variant'] as String? ?? kVariantS,
// 老目录无 kind 字段 → species2026-09-09 综合训练)
kind: j['kind'] as String? ?? kKindSpecies,
datasetIds: (j['datasetIds'] as List? ?? const [])
.map((e) => (e as num).toInt())
.toList(),
version: j['version'] as String? ?? '',
labels: (j['labels'] as List? ?? const [])
.map((e) => e.toString())
.toList(),
sizeBytes: (j['sizeBytes'] as num?)?.toInt() ?? 0,
sha256: j['sha256'] as String? ?? '',
downloadUrl: j['downloadUrl'] as String? ?? '',
coverUrl: j['coverUrl'] as String? ?? '',
);
}
/// 已就绪模型(字节 + 标签,供推理 worker 加载)
class ModelBundle {
final int datasetId;
final String datasetName;
final String variant;
final String version;
final List<String> labels;
final Uint8List bytes;
const ModelBundle({
required this.datasetId,
required this.datasetName,
required this.variant,
required this.version,
required this.labels,
required this.bytes,
});
}
/// 模型热更新管理:启动时拉取模型目录(随 /app/update 公开接口下发,无需登录态),
/// 按需下载/校验/持久化各数据集模型,供相机页多模型并行推理。
///
/// 双档位存储(2026-09-03):`models/<datasetId>/` 存放 s 档(legacy 布局,目录键 =
/// 档位标识符的「无子目录」形态,存量设备无需迁移),n 档存 `models/<datasetId>/n/`
/// 各目录含 model.tflite + labels.json + meta.jsonmeta 记录 {version, sha256}
/// 版本与摘要都未变化时跳过下载。记账键一律是 (datasetId, variant) 二元组。
/// 识别档位 [mode] 只是用户偏好(持久化:s 高精度默认 / n 高性能,切换不直接
/// 热换运行中模型):作为各卡默认目标档与「下载完成自动启用」的判定依据
/// 2026-09-11 多物种综合识别 App 端下线——不再展示综合卡,目录中 combined
/// 条目直接忽略,历史 multi_* / 两维识别方式配置自动归并到对应档位)。
/// 实际运行由激活集驱动——每个数据集**至多一个档位**在使用:激活某档会自动停用
/// 同数据集另一档,不同数据集可用不同档位并行识别(2026-09-03)。
/// 激活集是**会话态**2026-09-03 修订):每次进入视野页 [resetForSession] 清空、
/// 不跨会话持久化——识别需用户在模型清单手动启用(显式「下载」落地即启用目标档
/// 属于用户动作);上次崩溃/坏模型不会在下次打开时自动复现,用户总能看到仅预览
/// 界面并自行调整。
/// 目录**缓存优先**2026-09-03):最近一次成功拉取的 models 目录落盘
/// catalog.json[refresh] 开头先载入缓存并通知(弹层离线也有内容展示),网络
/// 成功后再以权威目录覆盖并落盘;清理/激活同步只在网络成功(fetched)后执行
/// ——缓存降级时不清文件不下载,离线首启不误删已下载模型。
/// 模型更新**手动制**2026-09-09):无自动更新/待办横幅,新版本由用户在
/// 卡片上重新下载(使用中重下会原地生效)。
class ModelManager extends ChangeNotifier {
static final ModelManager instance = ModelManager._();
final String baseUrl;
final http.Client _client;
final Future<Directory> Function()? _rootDirOverride;
List<ModelBundle> _models = const [];
List<ModelCatalogItem> _catalog = const [];
final Set<ModelKey> _active = {};
final Set<ModelKey> _downloaded = {};
final Map<ModelKey, double> _progress = {};
final Map<ModelKey, String> _errors = {};
final Set<ModelKey> _cancelRequested = {};
// 默认高性能(n):未持久化偏好时的新装默认档(2026-09-11 用户定案)
String _mode = kVariantN;
bool _modeLoaded = false;
bool _ready = false;
bool _refreshing = false;
String? _error;
Future<void>? _inFlight;
/// 模型文件/激活集变更版本戳:下载完成或激活变化 +1,
/// UI 据此判断是否需要重建推理 worker(2026-09-01 自动更新引入)
int _revision = 0;
int get revision => _revision;
/// 服务器目录(弹层模型清单展示用;同一数据集可能 s/n 两行)
List<ModelCatalogItem> get catalog => _catalog;
/// 识别档位偏好(s 高精度 / n 高性能):作为各卡默认目标档
/// (不直接切换运行——运行由激活集驱动)
String get mode => _mode;
bool isActive(int datasetId, String variant) =>
_active.contains((datasetId: datasetId, variant: variant));
/// 该 (数据集, 档位) 模型文件是否已下载到本地(同步判断,内存态)
bool isDownloaded(int datasetId, String variant) =>
_downloaded.contains((datasetId: datasetId, variant: variant));
/// 下载进度 0..1(无下载/已完成为 null)
double? progressOf(int datasetId, String variant) =>
_progress[(datasetId: datasetId, variant: variant)];
/// 下载失败原因(失败后可重试)
String? errorOf(int datasetId, String variant) =>
_errors[(datasetId: datasetId, variant: variant)];
/// 中断进行中的下载:下一个数据块到达时终止(丢弃 .part),卡片恢复「使用」。
/// 取消不记错误,可再次下载。
void cancelDownload(int datasetId, String variant) {
_cancelRequested.add((datasetId: datasetId, variant: variant));
}
ModelManager._({String? baseUrl, http.Client? client})
: this(baseUrl: baseUrl, client: client);
/// 可注入 baseUrl / client / 存储根目录(单测用)
@visibleForTesting
ModelManager({
String? baseUrl,
http.Client? client,
Future<Directory> Function()? rootDir,
}) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl,
_client = client ?? http.Client(),
_rootDirOverride = rootDir;
/// 已激活且已下载的模型列表(每个数据集至多一个档位;空 = 未加载任何模型,仅预览)
List<ModelBundle> get models => _models;
/// 是否成功拉取过目录(即使下载失败也为 true,用于区分"从未联网"与"目录为空"
bool get ready => _ready;
/// 模型目录拉取失败的错误信息(仅目录级;下载/校验失败见 errorOf)
String? get error => _error;
bool get refreshing => _refreshing;
/// 模型名摘要(诊断行展示):数据集名(同数据集的档位码不外显)
String get modelsLabel {
if (_models.isEmpty) return '未下载';
return _models.map((m) => m.datasetName).join(',');
}
/// 切换识别档位偏好(s 高精度 / n 高性能):只改默认目标档并持久化。
Future<void> setMode(String mode) async {
if (mode != kVariantS && mode != kVariantN) return;
if (_mode == mode) return;
_mode = mode;
await _saveMode();
notifyListeners();
}
/// 拉取目录并同步本地模型;并发调用共享同一进行中的刷新。
Future<void> refresh() {
if (_refreshing) return _inFlight ?? Future.value();
_refreshing = true;
_inFlight = _doRefresh().whenComplete(() {
_refreshing = false;
_inFlight = null;
notifyListeners();
});
return _inFlight!;
}
/// 开始新识别会话(进入视野页时调用):清空激活集与已加载模型。
/// 激活集为会话态、不做跨会话持久化——上次使用的模型不自动恢复,识别需
/// 用户在模型清单手动启用(2026-09-03 会话制修订)。
void resetForSession() {
if (_active.isEmpty) return;
_active.clear();
_models = const [];
_revision++;
notifyListeners();
}
Future<void> _doRefresh() async {
try {
await _loadMode();
// 缓存优先(2026-09-03):网络返回前先载入上次成功拉取的目录并提前 notify
// ——设置弹层打开即有内容展示,不依赖网络请求;网络成功后再以权威目录覆盖
if (_catalog.isEmpty) {
await _loadCatalogCache();
if (_catalog.isNotEmpty) notifyListeners();
}
var fetched = false;
try {
final res = await _client
.get(Uri.parse('$baseUrl/api/v1/app/update'))
.timeout(const Duration(seconds: 30));
// 服务器 Content-Type 无 charsethttp 包默认按 latin1 解码会乱码 → 显式 utf8
final body =
jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
final data = body['data'] as Map<String, dynamic>? ?? const {};
final list = data['models'] as List? ?? const [];
_catalog = list
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
.toList();
await _saveCatalogCache(list);
fetched = true;
} catch (e) {
// 拉取失败:保留缓存/旧目录继续展示;本实例从未拉取成功过才记错误
// (有缓存兜底时同样提示,说明当前展示的目录未经最新网络确认)
if (!_ready) _error = '模型目录拉取失败:$e';
}
// 无缓存且未拉取成功(目录确为空):无从同步,等下次刷新
if (_catalog.isEmpty && !fetched) return;
// 只拉目录不下载;扫描本地已有模型文件供清单展示(版本是否落后由
// 卡片对照目录版本提示「更新」)。缓存目录同样扫描:离线重开也能正确
// 标出已下载档位
final downloaded = <ModelKey>{};
for (final item in _catalog) {
if (await _hasFile(item)) {
downloaded.add((datasetId: item.datasetId, variant: item.variant));
}
}
_downloaded
..clear()
..addAll(downloaded);
// 清理/激活同步只认网络拉到的权威目录:缓存降级时不清文件——
// 离线首启不会误删已下载模型(2026-09-03)
if (!fetched) return;
await _prune(_catalog);
// 服务器已下线的 (数据集, 档位) 移出激活集
final catalogKeys = _catalog
.map((c) => (datasetId: c.datasetId, variant: c.variant))
.toSet();
_active.removeWhere((k) => !catalogKeys.contains(k));
_models = await _loadBundles(_catalog);
_ready = true;
_error = null;
} catch (e) {
if (!_ready) _error = '模型目录拉取失败:$e';
// 已就绪过则保留旧目录/旧模型,不覆盖 error(下载级错误优先展示)
}
}
/// 按需下载:流式下载 + sha256 校验 + 落盘(labels/meta)。
/// [autoActivate](默认 true,用户显式下载)该数据集此前无任何档位在使用且
/// 本档为目录中唯一可选/匹配目标档时自动激活(下载即有识别);
/// 使用中的档位原地更新则字节生效(重建推理 worker)。失败重试一次并记录错误。
Future<bool> downloadModel(ModelCatalogItem item,
{bool autoActivate = true,
void Function(int received, int total)? onProgress}) async {
final key = (datasetId: item.datasetId, variant: item.variant);
// 并发保护:同一 (数据集, 档位) 已有进行中的下载则直接短路
if (_progress.containsKey(key)) return false;
_cancelRequested.remove(key);
_progress[key] = 0;
final dir = await _modelDir(item.datasetId, item.variant);
final file = File('${dir.path}/model.tflite');
try {
for (var attempt = 0; attempt < 2; attempt++) {
if (_cancelRequested.contains(key)) break;
final ok = await _downloadAndVerify(item, dir, file,
onProgress: (r, t) {
_progress[key] = t == 0 ? 0 : r / t;
onProgress?.call(r, t);
notifyListeners();
});
if (ok) {
_progress.remove(key);
_errors.remove(key);
final wasActive = _active.contains(key);
_downloaded.add(key);
if (wasActive) {
// 使用中的模型原地更新:字节已替换,重建 worker 读新文件
_revision++;
_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) {
await setActive(item.datasetId, item.variant, true);
}
}
notifyListeners();
return true;
}
if (_cancelRequested.contains(key)) break;
await file.delete().catchError((_) => file);
await File('${dir.path}/model.tflite.part')
.delete()
.catchError((_) => file);
}
if (_cancelRequested.contains(key)) {
// 用户取消:清理残留,不记错误
await file.delete().catchError((_) => file);
await File('${dir.path}/model.tflite.part')
.delete()
.catchError((_) => file);
_progress.remove(key);
notifyListeners();
debugPrint('[ModelManager] 下载已取消: ${item.datasetName}');
return false;
}
_progress.remove(key);
_errors[key] = '下载失败,请重试';
notifyListeners();
debugPrint('[ModelManager] 下载失败: ${item.datasetName} ${item.version}');
return false;
} catch (e) {
if (_cancelRequested.contains(key)) {
await file.delete().catchError((_) => file);
await File('${dir.path}/model.tflite.part')
.delete()
.catchError((_) => file);
_progress.remove(key);
notifyListeners();
debugPrint('[ModelManager] 下载已取消: ${item.datasetName}');
return false;
}
_progress.remove(key);
_errors[key] = '下载异常:$e';
notifyListeners();
debugPrint('[ModelManager] 下载异常 ${item.datasetName}: $e');
return false;
}
}
Future<bool> _downloadAndVerify(
ModelCatalogItem item, Directory dir, File file,
{void Function(int received, int total)? onProgress}) async {
final part = File('${file.path}.part');
final sink = part.openWrite();
var received = 0;
try {
// 下载无总时长上限(大模型慢网可能数分钟);连接/响应头与数据流
// 分别做 30s 停滞判定,避免断流黑洞永久卡死
final res = await _client
.send(http.Request('GET', Uri.parse('$baseUrl${item.downloadUrl}')))
.timeout(const Duration(seconds: 30));
if (res.statusCode != 200) {
await sink.close();
return false;
}
final total = res.contentLength ?? item.sizeBytes;
await for (final chunk
in res.stream.timeout(const Duration(seconds: 30))) {
if (_cancelRequested.contains(
(datasetId: item.datasetId, variant: item.variant))) {
break; // 用户取消
}
sink.add(chunk);
received += chunk.length;
onProgress?.call(received, total);
}
if (_cancelRequested.contains(
(datasetId: item.datasetId, variant: item.variant))) {
await sink.close();
return false;
}
await sink.close();
final bytes = await part.readAsBytes();
final hex = sha256.convert(bytes).toString();
if (item.sha256.isNotEmpty && hex != item.sha256) {
debugPrint('[ModelManager] sha256 不匹配: ${item.datasetName} '
'want=${item.sha256} got=$hex');
return false;
}
await part.rename(file.path);
await dir.create(recursive: true);
await File('${dir.path}/labels.json')
.writeAsString(jsonEncode(item.labels));
await File('${dir.path}/meta.json').writeAsString(jsonEncode({
'version': item.version,
'sha256': item.sha256,
}));
debugPrint('[ModelManager] 已下载 ${item.datasetName}(${item.variant}) '
'${bytes.length}B -> ${file.path}');
return true;
} catch (e) {
await sink.close().catchError((_) {});
debugPrint('[ModelManager] 下载异常 ${item.datasetName}: $e');
return false;
}
}
/// 清理本地目录:数据集整体下线(s/n 两档都无目录条目)删整目录;
/// 数据集仍在但某档已下线时清该档子目录(s 档为同级文件,无独立目录,
/// 残留文件不再被引用,仅占用磁盘,不做细粒度清除)。
Future<void> _prune(List<ModelCatalogItem> catalog) async {
final root = await _rootDir();
if (!await root.exists()) return;
final dsIds = catalog.map((c) => c.datasetId).toSet();
final nDsIds = catalog
.where((c) => c.variant == kVariantN)
.map((c) => c.datasetId)
.toSet();
await for (final e in root.list()) {
if (e is! Directory) continue;
// 目录 URI 末尾带 '/'pathSegments 末位为空串 → 过滤后取目录名
final name = e.uri.pathSegments.where((s) => s.isNotEmpty).last;
final dsId = int.tryParse(name);
if (dsId == null) continue;
if (!dsIds.contains(dsId)) {
await e.delete(recursive: true).catchError((_) => e);
continue;
}
if (!nDsIds.contains(dsId)) {
final sub = Directory('${e.path}/$kVariantN');
if (await sub.exists()) {
await sub.delete(recursive: true).catchError((_) => e);
}
}
}
}
/// 设置激活状态(true=使用,false=取消;仅本次会话内生效,不持久化)。
/// 同一数据集至多一个档位在使用:激活某档时若同数据集另一档在使用则先停用
/// 2026-09-03:不同动物可跑不同档位,同一种动物一次只跑一档)。
/// **覆盖互斥(2026-09-09 综合模型)**:激活综合模型自动停用其 datasetIds 覆盖
/// 物种的单物种模型;激活某单物种自动停用覆盖它的综合模型(兜底:并存时跨模型
/// NMS 按类别名合并不会重复框,互斥只为省算力)。变化即重建推理 worker。
Future<void> setActive(int datasetId, String variant, bool active) async {
final key = (datasetId: datasetId, variant: variant);
if (!active) {
if (!_active.remove(key)) return;
} else {
final keyActive = _active.contains(key);
final conflicts = <ModelKey>{
..._active.where(
(k) => k.datasetId == datasetId && k.variant != variant),
};
// 覆盖互斥:找到本条目目录信息,按 kind 判定冲突集
ModelCatalogItem? catItem;
for (final c in _catalog) {
if (c.datasetId == datasetId && c.variant == variant) {
catItem = c;
break;
}
}
if (catItem != null && catItem.isCombined) {
// 综合:停用其覆盖物种的全部单物种激活
for (final k in _active) {
if (k.datasetId != 0 && catItem.datasetIds.contains(k.datasetId)) {
conflicts.add(k);
}
}
} else if (catItem != null) {
// 单物种:停用覆盖本物种的综合模型(同档位才冲突)
for (final k in _active) {
if (k.datasetId != 0) continue;
for (final c in _catalog) {
if (c.datasetId == 0 &&
c.variant == k.variant &&
c.isCombined &&
c.datasetIds.contains(datasetId)) {
conflicts.add(k);
}
}
}
}
if (keyActive && conflicts.isEmpty) return; // 状态未变化
_active.removeAll(conflicts);
_active.add(key);
}
_revision++;
_models = await _loadBundles(_catalog);
notifyListeners();
}
Future<void> _saveMode() async {
try {
final root = await _rootDir();
await root.create(recursive: true);
await File('${root.path}/mode.json')
.writeAsString(jsonEncode({'mode': _mode}));
} catch (e) {
debugPrint('[ModelManager] 识别偏好持久化失败: $e');
}
}
Future<void> _loadMode() async {
if (_modeLoaded) return;
_modeLoaded = true;
try {
final root = await _rootDir();
final f = File('${root.path}/mode.json');
if (!await f.exists()) return;
final data = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
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');
}
}
/// 载入上次成功拉取的目录缓存(catalog.json,模型根目录下)——离线/弱网时
/// 设置弹层也能先展示模型清单。无缓存文件/损坏/空列表则保持目录为空。
Future<void> _loadCatalogCache() async {
try {
final root = await _rootDir();
final f = File('${root.path}/catalog.json');
if (!await f.exists()) return;
final data = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
final list = data['models'] as List? ?? const [];
if (list.isEmpty) return;
_catalog = list
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
debugPrint('[ModelManager] 目录缓存读取失败: $e');
}
}
/// 落盘最近一次成功拉取的 models 原始列表(含服务器可能新增的字段),
/// 供下次离线/网络慢时先展示;缓存仅作展示降级,不参与清理/自动更新决策。
Future<void> _saveCatalogCache(List<dynamic> rawModels) async {
try {
final root = await _rootDir();
await root.create(recursive: true);
await File('${root.path}/catalog.json')
.writeAsString(jsonEncode({'models': rawModels}));
} catch (e) {
debugPrint('[ModelManager] 目录缓存保存失败: $e');
}
}
/// 读取全部激活条目(不再按目标档过滤——激活集即实际运行集,每数据集一档)
Future<List<ModelBundle>> _loadBundles(
List<ModelCatalogItem> catalog) async {
final bundles = <ModelBundle>[];
for (final item in catalog) {
final key = (datasetId: item.datasetId, variant: item.variant);
if (!_active.contains(key)) continue;
try {
final dir = await _modelDir(item.datasetId, item.variant);
final file = File('${dir.path}/model.tflite');
if (!await file.exists()) continue;
final labels = await File('${dir.path}/labels.json').exists()
? (jsonDecode(
await File('${dir.path}/labels.json').readAsString())
as List)
.map((e) => e.toString())
.toList()
: item.labels;
bundles.add(ModelBundle(
datasetId: item.datasetId,
datasetName: item.datasetName,
variant: item.variant,
version: item.version,
labels: labels,
bytes: await file.readAsBytes(),
));
} catch (e) {
debugPrint('[ModelManager] 读取 ${item.datasetName} 失败: $e');
}
}
return bundles;
}
Future<Directory> _rootDir() async {
if (_rootDirOverride != null) return _rootDirOverride();
final support = await getApplicationSupportDirectory();
return Directory('${support.path}/models');
}
/// 档位子路径(相对模型根目录):s 档 `models/<datasetId>/`legacy 无子目录,
/// 目录键 = 档位标识符的 s 形态,存量设备零迁移);n 档 `models/<datasetId>/n/`。
String _subPath(int datasetId, String variant) =>
variant == kVariantS ? '$datasetId' : '$datasetId/$variant';
/// 档位目录(不存在则创建)
Future<Directory> _modelDir(int datasetId, String variant) async {
final root = await _rootDir();
final dir = Directory('${root.path}/${_subPath(datasetId, variant)}');
await dir.create(recursive: true);
return dir;
}
/// 目录条目对应的模型文件是否已存在本地(不校验版本:文件已存在即标已下载,
/// 是否落后由卡片端对照目录版本提示「更新」)
Future<bool> _hasFile(ModelCatalogItem item) async {
final root = await _rootDir();
return File('${root.path}/${_subPath(item.datasetId, item.variant)}/model.tflite')
.exists();
}
}