Gemma:在任何手機裝置皆能運行,支援多模態(文字、影像)。
步驟1:安裝 flutter_gemma 套件
Package 連結:https://pub.dev/packages/flutter_gemma
flutter_gemma: ^0.9.0在「assets/models/」資料夾中放入「gemma-3n-E2B-it-int4.task」。
注意:此檔案約 3.14 GB。
assets:
- assets/models/步驟2:系統設定
2-1 Error: The plugin “flutter_gemma” requires a higher minimum iOS deployment version than your application is targeting. To build, increase your application’s deployment target to at least 16.0
ios/Podfile
platform :ios, '16.0'2-2 Error: The ‘Pods-Runner’ target has transitive dependencies that include statically linked binaries: (…MediaPipeTasksGenAI.xcframework…)
ios/Podfile
// use_frameworks!
use_frameworks! :linkage => :static2-3. Info.plist
Info.plist
<key>UIFileSharingEnabled</key>
<true/>步驟3:程式碼
main.dart
import 'package:flutter/material.dart';
import 'package:flutter_gemma/flutter_gemma.dart';
import 'package:flutter_gemma/core/chat.dart';
import 'package:flutter_gemma/core/model.dart';
import 'package:flutter_gemma/pigeon.g.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
),
debugShowCheckedModeBanner: false,
home: const ChatScreen(),
);
}
}
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
// 1. 狀態變數
final _gemma = FlutterGemmaPlugin.instance;
// 保留 InferenceModel 和 InferenceChat 的實例,以便後續操作與釋放資源
InferenceModel? _inferenceModel;
InferenceChat? _chat;
final TextEditingController _textController = TextEditingController();
final ScrollController _scrollController = ScrollController();
// 儲存對話訊息的列表
final List<Map<String, dynamic>> _messages = [];
bool _isLoading = true; // 用於顯示模型載入狀態
bool _isReplying = false; // 用於顯示 Gemma 是否正在回覆
// 2. 初始化
@override
void initState() {
super.initState();
_initializeGemma();
}
Future<void> _initializeGemma() async {
try {
// 指定模型檔案的 assets 路徑
const modelAssetPath = 'models/gemma-3n-E2B-it-int4.task';
// 直接呼叫 install 方法,它會處理將 asset 複製到可執行目錄的邏輯。
// 這個方法會自動檢查檔案是否已存在,若存在則不會重複複製。
await _gemma.modelManager.installModelFromAsset(modelAssetPath);
// 在安裝後,設定模型路徑,讓 createModel 知道要用哪個模型
await _gemma.modelManager.setModelPath(modelAssetPath);
// 建立模型實例,並將其存到 _inferenceModel 變數中
_inferenceModel = await _gemma.createModel(
modelType: ModelType.gemmaIt, // Gemma 指令調整模型
preferredBackend: PreferredBackend.gpu, // 優先使用 GPU 以獲得更好效能
supportImage: false, // 本範例不使用圖片功能
);
// 使用 _inferenceModel 建立對話實例
_chat = await _inferenceModel!.createChat();
// 更新 UI,顯示歡迎訊息
setState(() {
_isLoading = false;
_messages.add({
'text': '模型已載入!你好,我是 Gemma。有什麼可以幫助你的嗎?',
'isUser': false,
});
});
} catch (e) {
// 處理初始化過程中的錯誤
setState(() {
_isLoading = false;
_messages.add({'text': '模型載入失敗: $e', 'isUser': false});
});
debugPrint('初始化 Gemma 時發生錯誤: $e');
}
}
// 4. 處理訊息發送
Future<void> _sendMessage() async {
final queryText = _textController.text.trim();
// 如果輸入為空或正在回覆中,則不執行
if (queryText.isEmpty || _chat == null) return;
// 清空輸入框並更新 UI
_textController.clear();
setState(() {
_messages.add({'text': queryText, 'isUser': true});
_isReplying = true; // 開始等待回覆
_messages.add({'text': '...', 'isUser': false}); // 添加一個 "正在輸入..." 的佔位符
});
_scrollToBottom();
try {
// 將使用者輸入加入對話
await _chat!.addQueryChunk(Message.text(text: queryText, isUser: true));
String currentResponse = "";
// 使用異步串流接收回應,逐字更新 UI
await for (final token in _chat!.generateChatResponseAsync()) {
setState(() {
currentResponse += token;
// 更新最後一條訊息 (Gemma 的回覆)
_messages.last['text'] = currentResponse;
});
_scrollToBottom();
}
} catch (e) {
// 處理生成回應時的錯誤
setState(() {
_messages.last['text'] = '發生錯誤: $e';
});
debugPrint('生成回應時發生錯誤: $e');
} finally {
// 不論成功或失敗,最後都將回覆狀態設為 false
setState(() {
_isReplying = false;
});
}
}
// 滾動到對話列表底部
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
// 3. UI 介面
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
'Gemma',
style: TextStyle(color: Theme.of(context).colorScheme.onPrimary),
),
backgroundColor: Theme.of(context).colorScheme.primary,
),
body: Column(
children: [
Expanded(
child: _isLoading
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('正在載入模型,請稍候...'),
],
),
)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(8.0),
itemCount: _messages.length,
itemBuilder: (context, index) {
final message = _messages[index];
return _buildMessageBubble(
message['text'],
message['isUser'],
);
},
),
),
_buildChatInput(),
],
),
);
}
// 建立對話氣泡
Widget _buildMessageBubble(String text, bool isUser) {
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: isUser
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16.0),
),
child: Text(
text,
style: TextStyle(
color: isUser
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
),
);
}
// 建立聊天輸入框
Widget _buildChatInput() {
return Container(
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
boxShadow: [
BoxShadow(
offset: const Offset(0, -1),
blurRadius: 2,
color: Colors.black.withValues(alpha: 0.1),
),
],
),
child: SafeArea(
child: Row(
children: [
Expanded(
child: TextField(
controller: _textController,
decoration: InputDecoration(
hintText: '輸入訊息...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20.0),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0),
),
onSubmitted: (_) => _isReplying ? null : _sendMessage(),
),
),
const SizedBox(width: 8.0),
IconButton(
icon: const Icon(Icons.send),
onPressed: _isReplying ? null : _sendMessage,
style: IconButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
padding: const EdgeInsets.all(12),
),
),
],
),
),
);
}
@override
void dispose() {
_textController.dispose();
_scrollController.dispose();
_inferenceModel?.close();
super.dispose();
}
}