大家好!在昨天的進度中,我們的「省錢拍拍」App 成功地睜開了眼睛,能夠透過 image_picker
捕捉來自真實世界的發票圖片。但對 App 來說,這張圖片目前還只是一堆無意義的像素點。
今天,我們要賦予 App 一項超能力:閱讀。我們將深入探索 Google 強大的機器學習框架 ML Kit,實作光學字元辨識 (OCR) 功能。目標是將昨天拍下的那張圖片,轉換成 App 可以理解、可以處理的純文字資料,真正地化像素為價值!
首先,我們需要加入 Flutter 套件和 Android 平台的原生依賴。
flutter pub add google_mlkit_text_recognition
打開 android/app/build.gradle
檔案,在 dependencies { ... }
區塊中,加入中文辨識模型:
// android/app/build.gradle
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
// ML Kit 文字辨識(中文)- 加上這行
implementation 'com.google.mlkit:text-recognition-chinese:16.0.0'
}
因為我們同時修改了 Flutter 的 pubspec.yaml
和 Android 的原生設定 build.gradle
,為了確保所有變更都正確生效,最好執行一次完整的清理與重建。
在專案根目錄終端機中,依序執行以下指令:
flutter clean
flutter pub get
cd android
./gradlew clean # 在 Windows 上是 gradlew.bat clean
cd ..
flutter run
遵循我們在 Day 13 建立的良好習慣,並採納性能優化的建議,我們將 OCR 邏輯封裝在一個獨立的 Service 中。
在 lib/services
資料夾下建立 ocr_service.dart
:
// lib/services/ocr_service.dart
import 'dart.io';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
class OcrService {
// 共用一個 recognizer,減少重複載入模型與釋放的開銷
late final TextRecognizer _recognizer =
TextRecognizer(script: TextRecognitionScript.chinese);
bool _closed = false;
Future<String> processImage(File imageFile) async {
try {
final inputImage = InputImage.fromFilePath(imageFile.path);
final RecognizedText recognizedText =
await _recognizer.processImage(inputImage);
return recognizedText.text;
} catch (e) {
// 這裡只會捕捉 Dart 層例外;原生層崩潰需看裝置日誌
return '無法辨識圖片中的文字,請再試一次。錯誤:($e)';
}
}
// 提供 dispose 方法,讓外部可以在適當時機釋放資源
Future<void> dispose() async {
if (!_closed) {
await _recognizer.close();
_closed = true;
}
}
}
現在,我們回到 lib/scan_page.dart
,用更穩健的方式來處理圖片選擇、狀態管理和錯誤捕捉。
以下是 lib/scan_page.dart
檔案完整程式碼。
// lib/scan_page.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:snapsaver/services/ocr_service.dart';
class ScanPage extends StatefulWidget {
const ScanPage({super.key});
@override
State<ScanPage> createState() => _ScanPageState();
}
class _ScanPageState extends State<ScanPage> {
File? _imageFile;
final ImagePicker _picker = ImagePicker();
final OcrService _ocrService = OcrService();
bool _isProcessing = false;
String? _recognizedText;
Future<void> _pickImage(ImageSource source) async {
// 限制圖片大小,降低 OOM (Out of Memory) 的機率
final XFile? image = await _picker.pickImage(
source: source,
maxWidth: 1600,
maxHeight: 1600,
imageQuality: 85, // 適度壓縮圖片品質
);
if (image != null) {
setState(() {
_imageFile = File(image.path);
_recognizedText = null; // 選擇新圖片後清空舊結果
});
}
}
Future<void> _processImage() async {
if (_imageFile == null) return;
setState(() {
_isProcessing = true;
});
try {
final text = await _ocrService.processImage(_imageFile!);
if (!mounted) return;
setState(() {
_recognizedText = text;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('辨識失敗:$e')),
);
} finally {
if (mounted) {
setState(() {
_isProcessing = false;
});
}
}
}
@override
void dispose() {
_ocrService.dispose(); // 在頁面銷毀時釋放 recognizer
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('掃描發票')),
body: Column(
children: [
Expanded(
flex: 3,
child: _imageFile == null
? const Center(child: Text('尚未選擇圖片'))
: Container(
padding: const EdgeInsets.all(16.0),
child: Image.file(_imageFile!),
),
),
Expanded(
flex: 2,
child: _isProcessing
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Text(_recognizedText ?? '點擊右下角按鈕開始辨識...'),
),
),
],
),
bottomNavigationBar: BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
IconButton(icon: const Icon(Icons.camera_alt), onPressed: () => _pickImage(ImageSource.camera)),
IconButton(icon: const Icon(Icons.photo_library), onPressed: () => _pickImage(ImageSource.gallery)),
IconButton(
icon: const Icon(Icons.analytics),
onPressed: (_imageFile != null && !_isProcessing) ? _processImage : null,
tooltip: '開始辨識',
),
],
),
),
);
}
}
今天我們為「省錢拍拍」安裝了一個強大且高效的「閱讀器」!我們成功地:
OcrService
。我們已經將一張靜態的圖片,轉化為了一段充滿潛力的純文字資料。雖然這些文字目前看起來還有些雜亂,但它已經是 AI 大腦可以處理的「食材」。
明天,我們將把這些食材交給我們的「大廚」——正式對接 Google Gemini API
。我們將學習如何設計巧妙的「提問」(Prompt),讓 Gemini 從這段文字中,精準地為我們提取出金額、店家名稱等關鍵資訊!