15. 全本地处理架构设计:数据不出设备的安全扫描方案
在数据隐私焦虑的时代,"全程本地处理"不是锦上添花的功能,而是最强的差异化护城河。当竞品都在用云端API处理用户证件时,安心扫描选择了最难但最正确的路——所有图像处理、所有数据存储、所有算法运算,全部在用户设备上完成。本文完整拆解这套"数据不出设备"的架构设计,从四层分层到Provider拆分,从Isolate异步到ScanDocument持久化。
一、为什么"全程本地处理"是最强差异化
1.1 市场现状
| 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 重计算的场景
| 边缘检测(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;
}
}
持久化设计要点:
六、底部三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 重构前后对比
| 最大类行数 | 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() │
└─────────────────────────────┘
十二、小结
安心扫描的"全本地处理"架构不仅仅是一个技术选择,更是一个产品哲学——用户的数据属于用户,不应离开用户的设备。
这套架构的核心设计决策包括:
🔔 完整源码即将上架
下一篇预告: 《从CamScanner到自主实现:文档扫描算法对比与优化思路》——系列收官之作,将全面对比安心扫描与CamScanner、白描、Adobe Scan的功能矩阵,深入分析五级掩码级联边缘检测、红章保留等核心算法的差异,分享工程实践中"简单有效 > 过度工程"的核心教训,并提供完整的源码获取方式。
网硕互联帮助中心



评论前必须登录!
注册