云计算百科
云计算领域专业知识百科平台

15. 全本地处理架构设计:数据不出设备的安全扫描方案

15. 全本地处理架构设计:数据不出设备的安全扫描方案

在数据隐私焦虑的时代,"全程本地处理"不是锦上添花的功能,而是最强的差异化护城河。当竞品都在用云端API处理用户证件时,安心扫描选择了最难但最正确的路——所有图像处理、所有数据存储、所有算法运算,全部在用户设备上完成。本文完整拆解这套"数据不出设备"的架构设计,从四层分层到Provider拆分,从Isolate异步到ScanDocument持久化。


一、为什么"全程本地处理"是最强差异化

1.1 市场现状

产品图像处理位置OCR位置数据存储隐私风险
CamScanner 云端 联网云端 云端同步 高
Adobe Scan 云端 云端 Adobe Cloud 高
白描 本地 本地 本地 低
安心扫描 本地 本地 本地 最低

用户心理: 当用户扫描身份证时,最担心的不是"识别准不准",而是"我的身份证照片会不会泄露"。这个问题,云端处理的App无论怎么写隐私政策都无法完全消除——因为数据确实离开了设备。安心扫描从架构层面保证"数据不出设备",这是信任的根基。

1.2 全程本地处理的技术含义

全程本地处理 = 图像处理本地 + OCR本地 + 存储本地 + 无网络依赖

图像处理本地 → image package纯Dart算法,不调任何云端API
OCR本地 → Tesseract引擎内置,离线识别
存储本地 → ScanDocument + session.json + 图片文件,全在应用沙盒
无网络依赖 → 核心功能零网络请求,飞行模式也能用


二、四层架构设计

2.1 架构总览

┌─────────────────────────────────────────────────┐
│ UI 层 (Presentation) │
│ Screen + Widget + CustomPainter │
│ 职责:渲染界面、接收用户交互 │
├─────────────────────────────────────────────────┤
│ State 层 (State Management) │
│ ScanProvider + RecentsProvider │
│ 职责:管理UI状态、协调Service调用、notifyListeners│
├─────────────────────────────────────────────────┤
│ Service 层 (Business Logic) │
│ StorageService + PdfExportService + OcrService │
│ + WatermarkService + IdCopyService │
│ 职责:业务逻辑编排、Isolate调度、文件I/O │
├─────────────────────────────────────────────────┤
│ Algorithm 层 (Core Algorithms) │
│ EdgeDetection + Perspective + Dewarp + Filters │
│ + ScanPipeline + SpotRemoval + IdComposer │
│ 职责:纯算法实现、无状态、无副作用 │
└─────────────────────────────────────────────────┘

2.2 层间依赖规则

规则说明
UI → State UI通过Provider.of/Consumer读取状态,通过Provider方法触发操作
State → Service State层调用Service层完成业务操作
Service → Algorithm Service层调用Algorithm层执行具体算法
禁止反向依赖 Algorithm层不能引用UI层,Service层不能引用State层

核心原则:Algorithm层是"无状态纯函数"层。 所有算法函数只接收输入参数、返回输出结果,不持有任何状态、不产生副作用。这使得算法可以轻松地在Isolate中执行,也方便单元测试。

2.3 代码目录结构

lib/
├── main.dart # 入口(AnxinApp)
├── core/
│ ├── app_theme.dart # 主题系统(青绿种子色)
│ ├── app_routes.dart # 命名路由表
│ ├── image_math.dart # 图像数学工具
│ └── preview_mapping.dart # 预览坐标映射
├── models/
│ ├── scan_document.dart # ScanDocument + RecentEntry
│ ├── document_page.dart # DocumentPage
│ ├── page_settings.dart # PageSettings(滤镜/裁边配置)
│ └── export_settings.dart # ExportSettings(导出配置)
├── providers/
│ ├── scan_provider.dart # 当前扫描会话 + 最近文档索引
│ └── recents_provider.dart # 最近文档列表(从ScanProvider拆分)
├── services/
│ ├── storage_service.dart # 本地存储(沙盒目录管理)
│ ├── pdf_export_service.dart # PDF导出
│ ├── ocr_service.dart # 离线OCR
│ ├── watermark_service.dart # 水印服务
│ ├── id_copy_service.dart # 证件复印服务
│ ├── compress_service.dart # 图片压缩
│ ├── photo_id_service.dart # 证件照服务
│ ├── pdf_encryption.dart # PDF加密
│ ├── export_flow.dart # 导出流程编排
│ └── processing/
│ ├── edge_detection.dart # 边缘检测(五级掩码级联)
│ ├── perspective.dart # 透视变换
│ ├── dewarp.dart # 曲面展平
│ ├── filters.dart # 滤镜管线
│ ├── scan_pipeline.dart # 扫描处理管线
│ ├── spot_removal.dart # 污点去除
│ └── id_composer.dart # 证件A4合成
├── screens/
│ ├── main_navigation.dart # 底部三Tab导航壳
│ ├── home_screen.dart # 首页
│ ├── document_scan_screen.dart # 扫描入口
│ ├── camera_screen.dart # 相机拍摄
│ ├── crop_screen.dart # 裁边页
│ ├── enhance_screen.dart # 增强滤镜
│ ├── pages_screen.dart # 多页管理
│ ├── export_screen.dart # 导出设置
│ ├── id_card_screen.dart # 证件复印
│ ├── photo_id_screen.dart # 证件照
│ ├── text_extract_screen.dart # OCR文字提取
│ ├── compress_screen.dart # 图片压缩
│ └── watermark_screen.dart # 水印编辑
└── widgets/
├── corner_editor.dart # 四角拖拽编辑器
├── image_viewer.dart # 图片查看器
├── recent_docs_section.dart # 最近文档区块
├── media_source_sheet.dart # 媒体来源选择弹层
├── brush_canvas.dart # 画笔画布
└── id_guides.dart # 证件引导框


三、Provider状态管理拆分

3.1 God Object反模式

在项目初期,所有状态都塞进了一个 ScanProvider——它管理当前会话、最近文档列表、图像处理结果、缩略图缓存、忙碌标记……最终这个类承担了过多职责,任何修改都有可能引发连锁bug。

God Object的痛苦:

ScanProvider
├── 当前扫描会话 CRUD
├── 最近文档列表
├── 图像处理结果缓存
├── 缩略图生成
├── 导出设置
├── 页面排序
└── 忙碌/错误状态
→ 修改任何一处 → 可能影响其他所有功能
→ notifyListeners()触发 → 相关UI全部重绘

3.2 拆分策略

拆分后的Provider结构:

ScanProvider(~260行)
→ 职责:当前扫描会话的 CRUD + 最近文档索引缓存
→ 管理:current (ScanDocument?)、recents (List<RecentEntry>)、busy
→ 方法:newSession / loadSession / addCapturedImage / updatePage
removePage / reorderPages / ensureThumb / refreshThumb / deleteSession

RecentsProvider(~30行)
→ 职责:最近文档列表展示(从ScanProvider拆分)
→ 管理:recents (List<RecentEntry>)
→ 方法:init / refresh / deleteSession

关联方式:ScanProvider.linkRecents(RecentsProvider)
→ ScanProvider操作文档后自动调用 _recentsLink?.refresh()
→ RecentsProvider独立管理自己的UI刷新

3.3 ScanProvider实现

class ScanProvider extends ChangeNotifier {
ScanDocument? current;
List<RecentEntry> recents = [];
bool busy = false;

RecentsProvider? _recentsLink;

// 缩略图去重:正在生成的 id 与已确认失败的 id
final Set<String> _thumbInFlight = {};
final Set<String> _thumbFailed = {};

ScanDocument get doc => current!;

/// 建立与 RecentsProvider 的联动
void linkRecents(RecentsProvider rp) => _recentsLink = rp;

Future<void> init() async {
try {
recents = await StorageService.loadIndex();
} catch (_) {
recents = [];
}
if (hasListeners) notifyListeners();
}

/// 新建扫描会话
Future<void> newSession() async {
final now = DateTime.now();
current = ScanDocument(
id: '${now.millisecondsSinceEpoch}',
name: '扫描文档_${_fmt(now)}',
createdAt: now,
updatedAt: now,
);
await StorageService.saveSession(current!);
await _refreshRecents();
notifyListeners();
}

/// 清除当前会话引用(保证下一次扫描创建新文档)
void resetCurrent() {
current = null;
if (hasListeners) notifyListeners();
}

/// 载入会话;成功返回 true
Future<bool> loadSession(String id) async {
final d = await StorageService.loadSession(id);
if (d != null) {
current = d;
await _refreshRecents();
notifyListeners();
return true;
}
current = null;
notifyListeners();
return false;
}

/// 拍摄完成:复制进会话目录、读取尺寸、自动识别四角
Future<DocumentPage> addCapturedImage(String sourcePath,
{bool newDocument = false}) async {
busy = true;
notifyListeners();
try {
if (newDocument) current = null; // 强制新文档
if (current == null) await newSession();
final d = current!;
final pageId = 'p_${DateTime.now().microsecondsSinceEpoch}';
final dest = await StorageService.copyToSession(
d.id, sourcePath, 'orig_$pageId.jpg');
// Isolate中读取尺寸
final dims = await Isolate.run(() {
final im = decodeImageNormalized(File(dest).readAsBytesSync());
return im == null ? const (0, 0) : (im.width, im.height);
});
// Isolate中检测四角
List<P2>? corners;
try {
corners = await Isolate.run(() => detectDocumentCornersSync(dest));
} catch (_) {}
final page = DocumentPage(
id: pageId,
originalPath: dest,
originalWidth: dims.$1,
originalHeight: dims.$2,
settings: PageSettings(
applyCrop: false,
corners: corners ?? defaultCorners(),
useEnhance: false,
),
);
d.pages.add(page);
d.updatedAt = DateTime.now();
await StorageService.saveSession(d);
await _refreshRecents();
// 立即生成缩略图:确保最近文档列表显示处理后缩略图而非原图
await ensureThumb(page);
return page;
} finally {
busy = false;
notifyListeners();
}
}

Future<void> updatePage(DocumentPage page) async {
final d = current;
if (d == null) return;
final idx = d.pages.indexWhere((e) => e.id == page.id);
if (idx >= 0) {
d.pages[idx] = page;
d.updatedAt = DateTime.now();
await StorageService.saveSession(d);
await _refreshRecents();
notifyListeners();
}
}

Future<void> removePage(String id) async {
final d = current;
if (d == null) return;
d.pages.removeWhere((e) => e.id == id);
d.updatedAt = DateTime.now();
await StorageService.saveSession(d);
await _refreshRecents();
notifyListeners();
}

Future<void> reorderPages(int oldIndex, int newIndex) async {
final d = current;
if (d == null) return;
final e = d.pages.removeAt(oldIndex);
d.pages.insert(newIndex, e);
d.updatedAt = DateTime.now();
await StorageService.saveSession(d);
notifyListeners();
}

/// 生成缩略图并缓存路径(列表展示用)
Future<void> ensureThumb(DocumentPage page) async {
if (page.thumbPath != null && File(page.thumbPath!).existsSync()) return;
if (_thumbInFlight.contains(page.id) || _thumbFailed.contains(page.id)) {
return;
}
_thumbInFlight.add(page.id);
try {
final result = await processPageImage(
originalPath: page.originalPath,
s: page.settings,
maxDim: 400,
jpegQuality: 80,
);
final bytes = result.jpeg;
if (bytes.isEmpty) {
_thumbFailed.add(page.id);
return;
}
final td = await StorageService.thumbsDir();
// 版本化文件名:每次重新生成缩略图使用新文件名,
// 避免 Image.file 缓存旧图导致缩略图不刷新
final path = p.join(td.path, '${page.id}_${DateTime.now().millisecondsSinceEpoch}.jpg');
await File(path).writeAsBytes(bytes);
page.thumbPath = path;
await updatePage(page);
} catch (_) {
_thumbFailed.add(page.id);
} finally {
_thumbInFlight.remove(page.id);
}
}

/// 刷新缩略图:删除旧缓存后重新生成(设置变更后调用)。
/// 使用版本化文件名确保 Image.file 不会缓存旧图。
Future<void> refreshThumb(DocumentPage page) async {
if (page.thumbPath != null) {
final f = File(page.thumbPath!);
if (f.existsSync()) f.deleteSync();
page.thumbPath = null;
}
_thumbFailed.remove(page.id);
await ensureThumb(page);
}

Future<void> deleteSession(String id) async {
await StorageService.deleteSession(id);
if (current?.id == id) current = null;
await _refreshRecents();
notifyListeners();
}

Future<void> _refreshRecents() async {
recents = await StorageService.loadIndex();
_recentsLink?.refresh();
}
}

3.4 RecentsProvider实现

/// 最近文档列表 Provider(从原 ScanProvider 拆分)
class RecentsProvider extends ChangeNotifier {
List<RecentEntry> recents = [];

Future<void> init() async {
try {
recents = await StorageService.loadIndex();
} catch (_) {
recents = [];
}
if (hasListeners) notifyListeners();
}

/// 刷新最近文档列表
Future<void> refresh() async {
recents = await StorageService.loadIndex();
if (hasListeners) notifyListeners();
}

Future<void> deleteSession(String id) async {
await StorageService.deleteSession(id);
await refresh();
}
}

3.5 双向同步机制

双向同步流程:

用户在ScanProvider中添加扫描页
→ ScanProvider.addCapturedImage()
→ 复制原图到会话目录
→ 检测四角、创建DocumentPage
→ StorageService.saveSession(d)
→ _refreshRecents()
→ recents = StorageService.loadIndex()
→ _recentsLink?.refresh()
→ RecentsProvider.refresh()
→ notifyListeners()
→ 最近文档列表UI自动刷新

用户在RecentsProvider中删除文档
→ RecentsProvider.deleteSession()
→ StorageService.deleteSession(id)
→ refresh()
→ notifyListeners()
→ 最近文档列表UI自动刷新

为什么用双向引用而非EventBus? 安心扫描的Provider数量有限(2个核心Provider),双向引用(linkRecents())代码最直接、调用链最短、调试最简单。EventBus虽然解耦,但引入了"事件风暴"风险——一个事件可能触发多个监听器,难以追踪执行顺序。对于小规模App,显式调用比隐式事件更可靠。

3.6 Provider注册与关联

void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const AnxinApp());
}

class AnxinApp extends StatefulWidget {
const AnxinApp({super.key});


State<AnxinApp> createState() => _AnxinAppState();
}

class _AnxinAppState extends State<AnxinApp> {
late final RecentsProvider _recents;
late final ScanProvider _scan;


void initState() {
super.initState();
_recents = RecentsProvider();
_scan = ScanProvider();
_scan.linkRecents(_recents);
_recents.init();
_scan.init();
}


void dispose() {
_recents.dispose();
_scan.dispose();
super.dispose();
}


Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider<RecentsProvider>.value(value: _recents),
ChangeNotifierProvider<ScanProvider>.value(value: _scan),
],
child: MaterialApp(
title: '安心扫描',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: ThemeMode.system,
home: const MainNavigation(),
routes: AppRoutes.table,
),
);
}
}


四、Isolate异步处理

4.1 重计算的场景

操作计算量是否需要Isolate
边缘检测(250px实时) 小 是(~8ms,相机帧用Isolate避免掉帧)
边缘检测(500px离线) 中 是(~30ms)
透视矫正(全分辨率) 中 是(~500ms)
滤镜处理(导出分辨率) 中 是(~400ms)
A4合成(300dpi) 大 是(1.5s)
OCR识别(全页) 大 是(2~5s)
缩略图生成(400px) 小 是(异步不卡UI)

4.2 Isolate使用方式

安心扫描直接使用Flutter内置的 Isolate.run() 方法,它封装了Isolate的创建、通信和销毁,对简单的计算密集型任务足够方便:

// 尺寸读取
final dims = await Isolate.run(() {
final im = decodeImageNormalized(File(dest).readAsBytesSync());
return im == null ? const (0, 0) : (im.width, im.height);
});

// 边缘检测
List<P2>? corners;
try {
corners = await Isolate.run(() => detectDocumentCornersSync(dest));
} catch (_) {}

对于带进度回调的复杂任务(如多页PDF导出),采用循环+单步Isolate的方式,每处理一页更新一次进度:

/// 按画质处理整份文档(导出用)
Future<List<PdfPageData>> processAllForExport(
{bool highQuality = true}) async {
final d = current;
if (d == null) return [];
final maxDim = highQuality ? 2600 : 1600;
final quality = highQuality ? 92 : 70;
final results = <PdfPageData>[];
for (final page in d.pages) {
final r = await processPageImage(
originalPath: page.originalPath,
s: page.settings,
maxDim: maxDim,
jpegQuality: quality,
);
results.add((jpeg: r.jpeg, width: r.width, height: r.height));
}
return results;
}


五、ScanDocument持久化

5.1 数据模型

/// 一份多页扫描文档(会话)
class ScanDocument {
final String id;
String name;
final DateTime createdAt;
DateTime updatedAt;
List<DocumentPage> pages;
ExportSettings export;

ScanDocument({
required this.id,
required this.name,
required this.createdAt,
required this.updatedAt,
List<DocumentPage>? pages,
ExportSettings? export,
}) : pages = pages ?? [],
export = export ?? ExportSettings();

Map<String, Object?> toJson() => {
'id': id,
'name': name,
'createdAt': createdAt.millisecondsSinceEpoch,
'updatedAt': updatedAt.millisecondsSinceEpoch,
'pages': [for (final p in pages) p.toJson()],
'export': export.toJson(),
};

factory ScanDocument.fromJson(Map<String, dynamic> j) => ScanDocument(
id: j['id'] as String,
name: (j['name'] as String?) ?? '扫描文档',
createdAt: DateTime.fromMillisecondsSinceEpoch(
((j['createdAt'] as num?) ?? 0).toInt()),
updatedAt: DateTime.fromMillisecondsSinceEpoch(
((j['updatedAt'] as num?) ?? 0).toInt()),
pages: [
for (final p in (j['pages'] as List?) ?? const [])
DocumentPage.fromJson((p as Map).cast<String, dynamic>()),
],
export: ExportSettings.fromJson(
((j['export'] as Map?) ?? const {}).cast<String, dynamic>()),
);
}

/// 一个扫描页(一次拍摄 + 其编辑状态)
class DocumentPage {
final String id;
final String originalPath; // 会话目录内的原图
final int originalWidth, originalHeight;
String? processedPath; // 处理结果缓存路径
int processedWidth = 0, processedHeight = 0;
String? thumbPath; // 缩略图路径
PageSettings settings; // 滤镜/裁边设置

DocumentPage({
required this.id,
required this.originalPath,
required this.originalWidth,
required this.originalHeight,
PageSettings? settings,
}) : settings = settings ?? PageSettings();

// toJson / fromJson …
}

/// 首页「最近文档」条目(轻量索引)
class RecentEntry {
final String id;
final String name;
final DateTime updatedAt;
final int pageCount;
final String? coverPath;

RecentEntry({...});
// toJson / fromJson …
}

5.2 文件系统结构

应用沙箱 Documents/
└── scans/ # 扫描文档根目录
├── index.json # 最近文档索引(自动重建)
├── 1697123456789/ # 文档ID(时间戳)
│ ├── session.json # 文档元数据(原子写入)
│ ├── orig_p_xxx1.jpg # 第1页原图
│ ├── orig_p_xxx2.jpg # 第2页原图
│ └── …
├── 1697124567890/
│ ├── session.json
│ └── …
└── …

应用临时目录/
├── thumbs/ # 缩略图缓存(共享目录)
│ ├── p_xxx1.jpg
│ ├── p_xxx2.jpg
│ └── …
└── idcard/ # 证件复印临时工作目录
└── id_xxx.jpg

应用沙箱 Documents/
└── exports/ # 导出文件缓存目录

存储位置说明: 文档数据(原图+session.json)存放在 getApplicationDocumentsDirectory() 下的 scans/ 目录,属于应用持久化私有目录,系统不会自动清理。缩略图存放在 getTemporaryDirectory() 下的 thumbs/ 目录,系统可能在存储空间不足时清理,但删除后会自动重新生成。

5.3 StorageService实现

/// 本地存储:所有数据仅保存在应用私有沙盒目录,不上云、不外发。
class StorageService {
/// 扫描文档根目录(Documents/scans/)
static Future<Directory> scansRoot() async {
final docs = await getApplicationDocumentsDirectory();
return Directory(p.join(docs.path, 'scans')).create(recursive: true);
}

/// 导出文件根目录(Documents/exports/)
static Future<Directory> exportsRoot() async {
final docs = await getApplicationDocumentsDirectory();
return Directory(p.join(docs.path, 'exports')).create(recursive: true);
}

/// 单个文档目录
static Future<Directory> sessionDir(String id) async {
final root = await scansRoot();
return Directory(p.join(root.path, id)).create(recursive: true);
}

/// 缩略图目录(临时目录,系统可清理)
static Future<Directory> thumbsDir() async {
final t = await getTemporaryDirectory();
return Directory(p.join(t.path, 'thumbs')).create(recursive: true);
}

/// 把拍摄的照片复制进会话私有目录
static Future<String> copyToSession(
String sessionId, String sourcePath, String fileName) async {
final dir = await sessionDir(sessionId);
final dest = p.join(dir.path, fileName);
await File(sourcePath).copy(dest);
return dest;
}

/// 保存文档(原子写入:tmp + rename)
static Future<void> saveSession(ScanDocument doc) async {
final dir = await sessionDir(doc.id);
final target = File(p.join(dir.path, 'session.json'));
final tmp = File(p.join(dir.path, 'session.json.tmp'));
// 原子替换:先写临时文件再 rename,避免写到一半被杀进程
// 留下半截 session.json,导致文档被当成损坏而「消失」。
await tmp.writeAsString(jsonEncode(doc.toJson()));
await tmp.rename(target.path);
await _refreshIndex();
}

/// 加载单个文档
static Future<ScanDocument?> loadSession(String id) async {
final dir = await sessionDir(id);
final f = File(p.join(dir.path, 'session.json'));
if (!await f.exists()) return null;
try {
final map = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
return ScanDocument.fromJson(map);
} catch (_) {
return null;
}
}

/// 删除文档(同时清理共享缩略图)
static Future<void> deleteSession(String id) async {
// 先读出会话以收集缩略图路径(缩略图在共享 thumbs 目录,
// 不随会话目录删除,否则永久泄漏)。
try {
final doc = await loadSession(id);
if (doc != null) {
for (final page in doc.pages) {
final tp = page.thumbPath;
if (tp != null) {
final tf = File(tp);
if (await tf.exists()) {
try { await tf.delete(); } catch (_) {}
}
}
}
}
} catch (_) {}
final dir = await sessionDir(id);
if (await dir.exists()) await dir.delete(recursive: true);
await _refreshIndex();
}

/// 扫描会话目录重建「最近文档」索引(比增量维护更可靠)
static Future<List<RecentEntry>> loadIndex() async {
final root = await scansRoot();
final entries = <RecentEntry>[];
await for (final e in root.list()) {
if (e is! Directory) continue;
final f = File(p.join(e.path, 'session.json'));
if (!await f.exists()) continue;
try {
final map = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
final doc = ScanDocument.fromJson(map);
entries.add(RecentEntry(
id: doc.id,
name: doc.name,
updatedAt: doc.updatedAt,
pageCount: doc.pages.length,
coverPath: doc.pages.isNotEmpty
? (doc.pages.first.thumbPath ?? doc.pages.first.originalPath)
: null,
));
} catch (_) {}
}
entries.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
final indexFile = File(p.join(root.path, 'index.json'));
await indexFile.writeAsString(jsonEncode([
for (final e in entries) e.toJson(),
]));
return entries;
}
}

持久化设计要点:

  • 原子写入:session.json 使用 “写临时文件 + rename” 的原子替换策略,避免进程被杀导致文件损坏。
  • 索引重建:loadIndex() 每次都扫描整个 scans 目录重建索引,比增量维护更可靠(不怕异常操作导致索引不一致)。
  • 缩略图分离:缩略图放在临时目录的共享 thumbs 文件夹,不随会话目录删除,删除文档时需单独清理以防泄漏。
  • 时间戳存储:日期时间使用 millisecondsSinceEpoch 存储,比 ISO8601 字符串更紧凑、解析更快。
  • 缩略图版本化文件名:ensureThumb 生成缩略图时使用 ${page.id}_${timestamp}.jpg 命名,而非固定 ${page.id}.jpg。每次 refreshThumb 重新生成时产生新文件名,Image.file 因路径变化而绕过缓存加载新图,解决编辑后缩略图不刷新的问题。旧缩略图文件由 refreshThumb 显式删除。
  • 缩略图即时生成:addCapturedImage 在保存会话后立即调用 ensureThumb(page),确保 loadIndex() 时 thumbPath 已有值,coverPath 不会回退到 originalPath(原图路径),最近文档列表始终显示处理后缩略图。

  • 六、底部三Tab导航(MainNavigation)

    6.1 为什么用IndexedStack而非PageView

    方案保状态切换动画内存推荐度
    IndexedStack 全部Tab保状态 无 高(所有Tab同时构建) 推荐
    PageView 全部保状态 有滑动 中 可选
    Navigator路由 不保状态 有 低 不推荐

    安心扫描选IndexedStack的原因: 扫描页面包含相机预览、实时边缘追踪、已扫描页列表,这些状态一旦销毁重建,用户体验极差(相机重新初始化需要2~3秒)。IndexedStack虽然所有Tab同时存在于内存中,但能完美保持每个Tab的状态。

    6.2 MainNavigation实现

    // main_navigation.dart – 底部三Tab导航壳
    class MainNavigation extends StatefulWidget {
    const MainNavigation({super.key});


    State<MainNavigation> createState() => _MainNavigationState();
    }

    class _MainNavigationState extends State<MainNavigation> {
    int _currentIndex = 0;

    final List<Widget> _tabs = [
    const HomeScreen(), // 首页Tab
    const ToolsScreen(), // 工具Tab
    const DocumentsScreen(), // 文档Tab
    ];


    Widget build(BuildContext context) {
    return Scaffold(
    body: IndexedStack(
    index: _currentIndex,
    children: _tabs,
    ),
    bottomNavigationBar: NavigationBar(
    selectedIndex: _currentIndex,
    onDestinationSelected: (i) {
    setState(() => _currentIndex = i);
    },
    destinations: const [
    NavigationDestination(
    icon: Icon(Icons.home_outlined),
    selectedIcon: Icon(Icons.home),
    label: '首页',
    ),
    NavigationDestination(
    icon: Icon(Icons.build_outlined),
    selectedIcon: Icon(Icons.build),
    label: '工具',
    ),
    NavigationDestination(
    icon: Icon(Icons.folder_outlined),
    selectedIcon: Icon(Icons.folder),
    label: '文档',
    ),
    ],
    ),
    );
    }
    }

    6.3 多页管理页底部按钮设计

    多页管理页(PagesScreen)底部设计了三个等宽按钮,统一高度和文案长度:

    bottomNavigationBar: SafeArea(
    child: Padding(
    padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
    child: Row(
    children: [
    // 拍摄:跳转连续拍摄相机
    Expanded(child: SizedBox(
    height: 44,
    child: OutlinedButton.icon(
    style: OutlinedButton.styleFrom(
    padding: const EdgeInsets.symmetric(horizontal: 8),
    ),
    icon: const Icon(Icons.photo_camera_rounded, size: 20),
    label: const Text('拍摄', style: TextStyle(fontSize: 13)),
    onPressed: () => Navigator.push(
    MaterialPageRoute(builder: (_) =>
    const CameraScreen(continuous: true, newDocument: false))),
    ),
    )),
    const SizedBox(width: 8),
    // 相册:多选导入,追加到当前文档
    Expanded(child: SizedBox(
    height: 44,
    child: OutlinedButton.icon(
    icon: _importing
    ? const SizedBox(width: 16, height: 16,
    child: CircularProgressIndicator(strokeWidth: 2))
    : const Icon(Icons.photo_library_rounded, size: 20),
    label: Text(_importing ? '导入中' : '相册',
    style: const TextStyle(fontSize: 13)),
    onPressed: _importing ? null : () => _importFromGallery(),
    ),
    )),
    const SizedBox(width: 8),
    // 导出:跳转导出页
    Expanded(child: SizedBox(
    height: 44,
    child: FilledButton.icon(
    style: FilledButton.styleFrom(
    padding: const EdgeInsets.symmetric(horizontal: 8),
    ),
    icon: const Icon(Icons.picture_as_pdf_rounded, size: 20),
    label: const Text('导出', style: TextStyle(fontSize: 13)),
    onPressed: () => Navigator.push(
    MaterialPageRoute(builder: (_) => const ExportScreen())),
    ),
    )),
    ],
    ),
    ),
    ),

    按钮设计原则:三个按钮统一 height: 44 + padding: horizontal: 8 + fontSize: 13 + 简短文案(“拍摄”/“相册”/“导出”),等宽均分。

    6.4 从相册多选导入

    多页管理页和扫描入口页都支持从相册多选图片,合并为同一个文档的多页:

    Future<void> _importFromGallery() async {
    final pickedList = await ImagePicker()
    .pickMultiImage(requestFullMetadata: false);
    if (pickedList.isEmpty) return;

    final provider = context.read<ScanProvider>();
    for (var i = 0; i < pickedList.length; i++) {
    final normalized = await importPickedImage(pickedList[i].path);
    if (normalized == null) continue;
    // 第一张创建新文档,后续追加为同一文档的多页
    await provider.addCapturedImage(normalized, newDocument: i == 0);
    }
    // 直接跳转到多页管理页
    navigator.pushReplacement(
    MaterialPageRoute(builder: (_) => const PagesScreen()));
    }

    业务逻辑要点:多图导入与连续拍摄行为一致——第一张 newDocument: true 创建新文档,后续 newDocument: false 追加到当前文档。导入完成后直接跳转到多页管理页,用户可逐页编辑裁边/滤镜。


    七、命名路由表集中管理

    7.1 路由表设计

    /// 命名路由表:集中管理所有页面路由
    /// 路由名称按功能域分组,使用 / 分隔命名空间。
    class AppRoutes {
    // 功能域
    static const scan = '/scan';
    static const tools = '/tools';
    static const docs = '/docs';
    static const settings = '/settings';

    // 扫描流程
    static const documentScan = '/scan/document';
    static const camera = '/scan/camera';
    static const crop = '/scan/crop';
    static const enhance = '/scan/enhance';
    static const pages = '/scan/pages';
    static const export = '/scan/export';

    // 工具
    static const idCard = '/tools/idcard';
    static const photoId = '/tools/photoid';
    static const ocr = '/tools/ocr';
    static const compress = '/tools/compress';
    static const watermark = '/tools/watermark';

    static Map<String, WidgetBuilder> get table => {
    documentScan: (_) => const DocumentScanScreen(),
    camera: (_) => const CameraScreen(continuous: true, newDocument: true),
    pages: (_) => const PagesScreen(),
    export: (_) => const ExportScreen(),
    idCard: (_) => const IdCardScreen(),
    photoId: (_) => const PhotoIdScreen(),
    ocr: (_) => const TextExtractScreen(),
    compress: (_) => const CompressScreen(),
    watermark: (_) => const WatermarkScreen(),
    };

    /// 快捷 push
    static Future<T?> push<T>(BuildContext context, String routeName,
    {Object? arguments}) {
    return Navigator.of(context).pushNamed<T>(routeName, arguments: arguments);
    }

    /// 快捷 pushReplacement
    static Future<T?> pushReplacement<T>(BuildContext context, String routeName,
    {Object? arguments}) {
    return Navigator.of(context).pushReplacementNamed<T, dynamic>(
    routeName, arguments: arguments);
    }
    }

    7.2 带参数的路由跳转

    // 简单跳转
    AppRoutes.push(context, AppRoutes.enhance);

    // 带参数跳转
    Navigator.pushNamed(
    context,
    AppRoutes.crop,
    arguments: {'pageId': page.id},
    );

    // 在目标页面接收参数
    class CropScreen extends StatelessWidget {
    const CropScreen({super.key});


    Widget build(BuildContext context) {
    final args = ModalRoute.of(context)?.settings.arguments
    as Map<String, dynamic>?;
    final pageId = args?['pageId'] as String?;
    // …
    }
    }

    集中管理的好处: 路由名统一定义在 AppRoutes 常量中,按功能域分组(/scan/*、/tools/*),IDE可以自动补全,避免拼写错误。新增页面只需在常量和table中各加一行,一目了然。


    八、God Object重构经验总结

    8.1 重构前后对比

    指标重构前(God Object)重构后(拆分Provider)
    最大类行数 400+行 ScanProvider ~260行
    RecentsProvider ~30行
    单次notifyListeners影响范围 全App 单一功能域
    修改一个功能的bug风险 可能影响其他功能 隔离
    单元测试难度 高(需要mock全部依赖) 低(只mock StorageService)
    新成员上手难度 高(需理解全部状态) 低(各Provider职责清晰)

    8.2 重构原则

    God Object拆分三原则:

    1. 单一职责原则
    → 一个Provider只管一个功能域
    → ScanProvider管"当前扫描会话",RecentsProvider管"最近文档列表"

    2. 状态归属原则
    → 谁的UI用谁的状态
    → 首页最近文档归RecentsProvider,不归ScanProvider

    3. 最小通知原则
    → notifyListeners只通知关心自己的UI
    → 避免一个Provider的notify触发全App重绘

    重构时机: 不要等到400行才拆。当一个Provider超过300行,或者它管理了3个以上不相关的状态域时,就应该拆分了。早拆比晚拆容易得多——后期拆分需要处理大量已有引用。


    九、架构安全分析

    9.1 数据流向图

    用户拍摄文档
    → 原图存入应用沙盒 Documents/scans/(不出设备)
    → Isolate处理(内存中,不传网络)
    → 处理结果存回会话目录
    → UI从设备读取展示
    → 用户导出(可选,用户主动操作)

    全程零网络请求:
    → 无图片上传
    → 无OCR API调用
    → 无云端同步
    → 无分析数据上报

    9.2 安全保障措施

    措施实现效果
    数据不出设备 所有处理在Isolate内存中 即使抓包也看不到图片数据
    无网络权限 核心功能不请求INTERNET 飞行模式完全可用
    设备沙盒存储 ScanDocument在App私有Documents目录 其他App无法读取
    无第三方SDK 不引入任何分析/广告SDK 减少数据泄露面
    无云同步 不实现任何云端备份 数据只存在一份(本地)
    原子写入 session.json tmp+rename 防止进程被杀导致数据损坏

    "无网络权限"的验证方法: 在Android Manifest中不声明 INTERNET 权限(除非用户主动使用分享功能),系统层面阻止App发起任何网络请求。这是最强的隐私保障——不是"承诺不上传",而是"技术上无法上传"。


    十、架构优缺点总结

    10.1 优点

    优点说明
    隐私最强 数据不出设备,信任根基
    离线可用 飞行模式/无网络环境完全可用
    无服务器成本 不需要维护云端API和存储
    无API限流 不受第三方OCR API的QPS限制
    响应速度快 本地处理无网络延迟
    架构清晰 四层分层 + Provider拆分
    数据可靠 原子写入 + 索引重建机制

    10.2 缺点与应对

    缺点应对方案
    本地OCR精度低于云端 内置Tesseract + 优化训练数据
    设备存储空间占用 提供清理功能 + 缩略图可自动清理
    重计算依赖设备性能 Isolate异步 + 动态降级(fast/thorough模式)
    无多设备同步 这是设计选择,非缺陷
    无云端备份 提供导出功能让用户自行备份

    十一、完整架构数据流图

    ┌────────── UI 层 ──────────┐
    │ HomeScreen │
    │ ├── GreetingHeader │
    │ ├── ModuleGrid │
    │ └── RecentDocsSection ◀─┐
    │ PagesScreen │
    │ ├── PageThumbList │
    │ └── BottomActionBar │
    │ CropScreen │
    │ ├── CornerEditor │
    │ └── ImageViewer │
    └──────────┬─────────────────┘
    │ Provider.of / Consumer
    ▼
    ┌────────── State 层 ────────┐
    │ ScanProvider │
    │ ├── current (ScanDoc?) │
    │ ├── recents [] │
    │ ├── busy │
    │ ├── addCapturedImage() │
    │ └── linkRecents() ───────┐
    │ RecentsProvider │
    │ ├── recents[] │
    │ └── refresh() ◀──────────┘
    └──────────┬─────────────────┘
    │ 调用
    ▼
    ┌────────── Service 层 ──────┐
    │ StorageService (static) │
    │ ├── saveSession() │
    │ ├── loadSession() │
    │ ├── deleteSession() │
    │ └── loadIndex() │
    │ PdfExportService │
    │ └── exportPdf() │
    │ OcrService │
    │ └── extractText() │
    │ WatermarkService │
    │ ├── renderWatermarkTile()│
    │ └── stampWatermarkTile()│
    └──────────┬─────────────────┘
    │ Isolate调度
    ▼
    ┌────── Algorithm 层 ────────┐
    │ EdgeDetection │
    │ ├── detectDocumentCorners() │
    │ └── 五级掩码级联 │
    │ Perspective │
    │ └── perspectiveCorrect() │
    │ Dewarping │
    │ └── 曲面展平 │
    │ Filters │
    │ ├── applyFilterStack() │
    │ └── 红章保留 │
    │ ScanPipeline │
    │ └── processPageImage() │
    └─────────────────────────────┘


    十二、小结

    安心扫描的"全本地处理"架构不仅仅是一个技术选择,更是一个产品哲学——用户的数据属于用户,不应离开用户的设备。

    这套架构的核心设计决策包括:

  • 四层分层: UI → State → Service → Algorithm,每层职责清晰
  • Provider拆分: 从God Object到单一职责的多个Provider
  • 双向同步: ScanProvider与RecentsProvider通过linkRecents关联
  • Isolate异步: 重计算不卡UI
  • ScanDocument持久化: session.json + 原子写入 + 索引自动重建
  • 存储位置: 文档在Documents/scans/,缩略图在临时目录thumbs/
  • 缩略图版本化命名: ${page.id}_${timestamp}.jpg 绕过 Image.file 缓存,编辑后实时刷新
  • IndexedStack保状态: 底部Tab切换不销毁状态
  • 命名路由集中管理: AppRoutes按功能域分组
  • 零网络依赖: 核心功能完全离线,隐私从架构层面保障
  • 🔔 完整源码即将上架

    下一篇预告: 《从CamScanner到自主实现:文档扫描算法对比与优化思路》——系列收官之作,将全面对比安心扫描与CamScanner、白描、Adobe Scan的功能矩阵,深入分析五级掩码级联边缘检测、红章保留等核心算法的差异,分享工程实践中"简单有效 > 过度工程"的核心教训,并提供完整的源码获取方式。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 15. 全本地处理架构设计:数据不出设备的安全扫描方案
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!