快速整合
本頁從零開始做一個能收發訊息的 Flutter App:完成平臺配置、建立客戶端、用業務服務端簽發的憑證登入、顯示會話列表、開啟會話檢視訊息、傳送文字訊息、收到新訊息時提醒、退出登入。完成後你會得到一個可以直接執行的示例,可以在它的基礎上開發自己的介面。
準備工作
- 在控制檯使用註冊時自動建立的
demo應用,或另外建立應用,在概覽的“接入憑證”中複製 AppKey(如1575529652#demo)和 IM 服務的地址,即“REST 接入地址”(如https://im.example.com)。 - 業務服務端接入服務端 REST API,提供一個“獲取 IM 登入憑證”的介面:使用者登入你的業務系統後,App 呼叫這個介面,業務服務端為當前使用者簽發登入憑證並返回,如
{"ticket": "ult_..."}。 - 安裝 Flutter 3.38.1 以上;構建 iOS、macOS 版本需要裝有 Xcode 的 Mac,構建 Android 版本需要 Android SDK 和 JDK 17。
憑證 5 分鐘內有效、只能使用一次,相當於使用者的臨時密碼,只透過 HTTPS 返回給這個使用者的 App。App Token 只能儲存在服務端,不能放在 App 中。開發和測試時,也可以用 curl 簽發一個憑證,複製到 App 中使用。
建立工程並安裝
flutter create --platforms=android,ios im_demo
cd im_demo
flutter pub add deeprespond_im_flutterdeeprespond_im_flutter 包含收發訊息需要的全部 API,本頁只用到它。需要離線推送、音視訊通話時再新增 deeprespond_im_push、deeprespond_im_call,見概述。
平臺配置
加密本地資料庫(可選)
SDK 的本地資料庫預設明文 SQLite;要啟用 SQLCipher 加密,在 App 的 pubspec.yaml 中加入構建鉤子配置, 並在建立客戶端時給出 encryptDatabase: true:
hooks:
user_defines:
sqlite3:
source:
android: sqlcipher
ios: sqlcipher
macos: sqlcipher
linux: system
default: sqlcipherfinal im = await DRClient.create(DRClientOptions(
// ...
encryptDatabase: true,
));沒有把構建換成 SQLCipher 時不要開啟
encryptDatabase,SDK 會以unsupported拒絕建立客戶端。
Android
SDK 需要的網路、網路狀態和錄音許可權已在包中宣告,構建時自動合併到 App 的清單中,不需要你再寫。需要檢查的是:
android/app/build.gradle.kts中minSdk不低於 24,compileSdk建議為 36。Flutter 3.38 起的預設值(flutter.minSdkVersion、flutter.compileSdkVersion)已經滿足,不需要修改;你的工程中寫了更低的值時請改為:kotlinandroid { compileSdk = 36 defaultConfig { minSdk = 24 } }備份:SDK 的登入狀態儲存在系統的安全儲存中,它的加密資料被 Android 的自動備份帶到新手機後無法解密,使用者要重新登入。建議在備份規則中排除它(SDK 自己的資料庫不參與備份,不需要配置)。在
android/app/src/main/AndroidManifest.xml的<application>上指定備份規則:xml<application android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules" ...>android/app/src/main/res/xml/backup_rules.xml(Android 11 及以下):xml<?xml version="1.0" encoding="utf-8"?> <full-backup-content> <exclude domain="sharedpref" path="deeprespond_im.xml" /> <exclude domain="sharedpref" path="FlutterSecureKeyStorage:deeprespond_im.xml" /> </full-backup-content>android/app/src/main/res/xml/data_extraction_rules.xml(Android 12 起):xml<?xml version="1.0" encoding="utf-8"?> <data-extraction-rules> <cloud-backup> <exclude domain="sharedpref" path="deeprespond_im.xml" /> <exclude domain="sharedpref" path="FlutterSecureKeyStorage:deeprespond_im.xml" /> </cloud-backup> <device-transfer> <exclude domain="sharedpref" path="deeprespond_im.xml" /> <exclude domain="sharedpref" path="FlutterSecureKeyStorage:deeprespond_im.xml" /> </device-transfer> </data-extraction-rules>App 不需要備份時,也可以直接在
<application>上設定android:allowBackup="false"。開發時 IM 服務的地址是
http://的,要允許明文流量,只寫在android/app/src/debug/AndroidManifest.xml中,不要帶到正式版本:xml<application android:usesCleartextTraffic="true" />
iOS
部署目標:不低於 iOS 15.0。在
ios/Podfile中設定platform :ios, '15.0',並在 Xcode 中把 Runner 的 Minimum Deployments 設為 15.0。用途說明:在
ios/Runner/Info.plist中加入麥克風的用途說明(SDK 帶有錄音功能);使用視訊通話的,另加攝像頭的用途說明:xml<key>NSMicrophoneUsageDescription</key> <string>用于发送语音消息和语音通话</string> <key>NSCameraUsageDescription</key> <string>用于视频通话</string>開發時 IM 服務的地址是
http://的區域網地址,要在開發用的Info.plist中允許本地網路的明文請求(正式版本請使用 HTTPS):xml<key>NSAppTransportSecurity</key> <dict> <key>NSAllowsLocalNetworking</key> <true/> </dict>
macOS
構建 macOS 版本時,部署目標不低於 macOS 12.0(macos/Podfile 中的 platform :osx, '12.0' 和 Xcode 中的 Minimum Deployments),並在 macos/Runner/DebugProfile.entitlements 和 Release.entitlements 中都加入網路訪問和鑰匙串的權利(SDK 把登入狀態儲存在鑰匙串中):
<key>com.apple.security.network.client</key>
<true/>
<key>keychain-access-groups</key>
<array/>推送和來電
本頁的示例不需要它們,接入時的配置分別見離線推送和音視訊通話,概括如下:
- iOS:在 Xcode 中為 Runner 新增 Push Notifications 和 Background Modes 能力,後臺模式勾選 Remote notifications(推送),使用通話的另外勾選 Voice over IP 和 Audio, AirPlay, and Picture in Picture;
Info.plist中的UIBackgroundModes相應為remote-notification、voip、audio。接入 CallKit 的,在AppDelegate中呼叫DRCallKit.setUp(),見音視訊通話。 - Android:FCM 需要
google-services.json和 Google Services 的 Gradle 外掛;國內廠商通道需要各廠商的配置,見離線推送。 main()中的位置:Firebase.initializeApp()、FirebaseMessaging.onBackgroundMessage(...)(其中呼叫drFirebaseBackgroundHandler)和廠商通道的註冊函式(如registerXiaomiPush(...))都在建立客戶端之前呼叫,見下文建立客戶端中的註釋和初始化與後臺處理函式。
建立客戶端
在 main() 中、runApp 之前建立客戶端,整個 App 共用這一個:
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
import 'package:flutter/material.dart';
late final DRClient im;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// 接入推送时,在这里(创建客户端之前):
// Android 上 await Firebase.initializeApp(); FirebaseMessaging.onBackgroundMessage(onBackgroundMessage);
// (onBackgroundMessage 中调用 drFirebaseBackgroundHandler),以及厂商通道的 registerXiaomiPush(...) 等,
// 并在下面的选项中给出 push、call,见“离线推送”“音视频通话”
im = await DRClient.create(DRClientOptions(
appKey: '1575529652#demo',
apiUrl: Uri.parse('https://im.example.com'),
platform: DRFlutterPlatform.instance,
));
runApp(const MaterialApp(home: Placeholder()));
}- 呼叫
DRClient.create之前必須先呼叫WidgetsFlutterBinding.ensureInitialized()。 - 建立客戶端不發請求,返回前讀取本地儲存的登入狀態。這臺裝置上已經登入過的,SDK 立即恢復登入(
im.auth.currentUser不為null),不需要再登入。
登入
向業務服務端取得登入憑證,交給 SDK 登入。下面用 dart:io 的 HttpClient 請求業務服務端,你也可以使用自己習慣的網路庫:
import 'dart:convert';
import 'dart:io';
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
/// 向业务服务端获取 IM 登录凭证(/api/im-ticket 是你自己定义的接口,用户的身份取自你的业务系统的登录状态)。
Future<String> fetchTicket() async {
final http = HttpClient();
try {
final req = await http.postUrl(Uri.parse('https://your-server.example.com/api/im-ticket'));
req.headers.set(HttpHeaders.authorizationHeader, 'Bearer 你的业务系统的登录令牌');
final res = await req.close();
final text = await res.transform(utf8.decoder).join();
if (res.statusCode != 200) throw HttpException('获取 IM 登录凭证失败:${res.statusCode}');
return (jsonDecode(text) as Map<String, Object?>)['ticket']! as String;
} finally {
http.close();
}
}
Future<DRClient> createClient() => DRClient.create(DRClientOptions(
appKey: '1575529652#demo',
apiUrl: Uri.parse('https://im.example.com'),
platform: DRFlutterPlatform.instance,
// 凭证失效、或登录状态过期时,SDK 用它重新取一次凭证
ticketProvider: (context) => fetchTicket(),
));
Future<void> signIn(DRClient im) async {
if (im.auth.currentUser != null) return; // 已经登录(自动恢复)
final result = await im.auth.loginWithTicket(ticket: await fetchTicket());
print('已登录 ${result.user.username}');
}登入成功後,SDK 在後臺連線長連線並同步資料。使用者關閉 App 再開啟時會自動恢復登入,所以只在 im.auth.currentUser 為 null 時才需要登入。登入失敗的處理見初始化、登入與連線。
顯示會話列表
im.conversations.list() 返回一個可訂閱的會話列表,按置頂和最近訊息的時間排序。資料先從本地讀取,同步到新資料時自動更新;用 LiveListBuilder 在列表變化時重建介面:
import 'package:deeprespond_im_flutter/render.dart';
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
import 'package:flutter/material.dart';
class ConversationsView extends StatefulWidget {
const ConversationsView({super.key, required this.im, required this.onOpen});
final DRClient im;
final void Function(String conversationKey) onOpen;
@override
State<ConversationsView> createState() => _ConversationsViewState();
}
class _ConversationsViewState extends State<ConversationsView> {
// 列表必须在登录之后创建;不再显示时释放
late final ConversationList _list = widget.im.conversations.list();
@override
void dispose() {
_list.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final im = widget.im;
final me = im.auth.currentUser?.username;
return LiveListBuilder<ConversationView>(
list: _list,
builder: (context, snapshot) => ListView.builder(
itemCount: snapshot.items.length,
itemBuilder: (context, i) {
final c = snapshot.items[i];
final last = c.lastMessage;
return ListTile(
key: ValueKey(c.conversationKey),
title: Text(conversationTitle(im, c)),
subtitle: Text(last == null ? '' : summarize(last, nameOf: (u) => displayName(im, u), self: me), maxLines: 1),
trailing: c.unreadCount > 0 ? Badge(label: Text('${c.unreadCount}')) : null,
onTap: () => widget.onOpen(c.conversationKey),
);
},
),
);
}
}conversationTitle 給出會話的標題:單聊為對方的備註或暱稱,群聊為群名;summarize 給出最後一條訊息的摘要(如“[圖片]”)。列表必須在登入之後建立,未登入時呼叫會丟擲 not_signed_in。
開啟會話並顯示訊息
使用者點選會話時,用它的 conversationKey 開啟訊息列表;要和某個人開始聊天,先用 openSingle 取得與他的單聊:
// 与某个人开始聊天(还没有会话时得到一个本地的草稿会话)
final view = await im.conversations.openSingle('lisi');
final messages = im.messages.open(view.conversationKey);
messages.stream.listen((snapshot) {
for (final m in snapshot.items) {
final text = m.type == 'text' ? '${m.body?['text'] ?? ''}' : '[${m.type}]';
print('${m.sender}:$text(${m.local.status})');
}
});
messages.setViewing(visible: true, atBottom: true); // 用户正在看这个会话的最新消息
messages.markRead(); // 把这个会话标记为已读- 訊息按順序排列,最新的在最後;還在傳送中的訊息排在最後,
local.status為sending、failed等。 - 開啟時顯示本地最新的一頁,向上翻頁時呼叫
messages.loadOlder()。 - 離開這個會話時呼叫
messages.dispose()釋放列表。
傳送文字訊息
final sent = await im.messages.send(SendTarget.conversation(messages.conversationKey), OutgoingContent.text('你好'));
print('${sent.clientMsgId} ${sent.local.status}'); // queued:已进入发送队列send 在訊息進入傳送佇列後立即返回,訊息同時出現在開啟著的訊息列表中。之後 SDK 負責傳送和失敗重試,狀態變化透過訊息列表通知你;網路斷開時訊息留在佇列中,恢復後自動發出。也可以不開啟會話,直接發給某個人或某個群:SendTarget.user('lisi')、SendTarget.group('群 ID')。
監聽新訊息和連線狀態
im.on<MessageReceived>().listen((e) {
// 按免打扰、提醒设置判断是否需要提醒;App 在前台时由你在界面中提示
if (e.notify.notify) showToast(e.notify.body ?? '${e.message.sender} 发来一条新消息');
});
im.connection.stateStream.listen((state) {
// ConnectionReady:已连接并同步完成;ConnectionWaiting:等待重连;ConnectionOffline:网络不可用
print(switch (state) {
ConnectionReady() => '',
ConnectionOffline() => '网络不可用',
_ => '连接中…',
});
});
im.auth.stateStream.listen((state) {
if (state is SessionEnded) showToast('登录已失效,请重新登录(${state.reason})');
});新訊息到達時,開啟著的會話列表和訊息列表會自動更新,不需要你在事件中處理;MessageReceived 只用於在介面中提示、播放提示音等。App 在後臺時的提醒由離線推送完成,見離線推送。全部事件見事件與錯誤處理。
退出
await im.auth.logout();退出後 SDK 清除本地的登入狀態、斷開長連線,開啟著的列表被清空並釋放。多人共用的裝置上,可以用 logout(clearLocalData: true) 同時刪除這個使用者在本地的聊天記錄。
完整示例
把以下程式碼替換為 lib/main.dart,修改其中的 AppKey、服務地址和取憑證的介面,執行 flutter run。用兩臺裝置(或一臺裝置加一個模擬器)以兩個使用者登入,就可以互相發訊息。
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:deeprespond_im_flutter/render.dart';
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
import 'package:flutter/material.dart';
Future<String> fetchTicket() async {
final http = HttpClient();
try {
final req = await http.postUrl(Uri.parse('https://your-server.example.com/api/im-ticket'));
req.headers.set(HttpHeaders.authorizationHeader, 'Bearer 你的业务系统的登录令牌');
final res = await req.close();
final text = await res.transform(utf8.decoder).join();
if (res.statusCode != 200) throw HttpException('获取 IM 登录凭证失败:${res.statusCode}');
return (jsonDecode(text) as Map<String, Object?>)['ticket']! as String;
} finally {
http.close();
}
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final im = await DRClient.create(DRClientOptions(
appKey: '1575529652#demo',
apiUrl: Uri.parse('https://im.example.com'),
platform: DRFlutterPlatform.instance,
ticketProvider: (context) => fetchTicket(),
));
runApp(DemoApp(im: im));
}
class DemoApp extends StatefulWidget {
const DemoApp({super.key, required this.im});
final DRClient im;
@override
State<DemoApp> createState() => _DemoAppState();
}
class _DemoAppState extends State<DemoApp> {
final _navigator = GlobalKey<NavigatorState>();
final _messenger = GlobalKey<ScaffoldMessengerState>();
final _subs = <StreamSubscription<Object?>>[];
DRClient get im => widget.im;
@override
void initState() {
super.initState();
_subs.add(im.auth.stateStream.listen((state) {
// 登录、退出、会话结束时切换页面
_navigator.currentState?.popUntil((route) => route.isFirst);
setState(() {});
if (state is SessionEnded) _toast('登录已失效,请重新登录');
}));
_subs.add(im.on<MessageReceived>().listen((e) {
if (e.notify.notify) _toast('${displayName(im, e.message.sender)}:${summarize(e.message, nameOf: (u) => displayName(im, u))}');
}));
}
@override
void dispose() {
for (final sub in _subs) {
unawaited(sub.cancel());
}
super.dispose();
}
void _toast(String text) => _messenger.currentState?.showSnackBar(SnackBar(content: Text(text)));
@override
Widget build(BuildContext context) {
final state = im.auth.state;
final signedIn = state is SignedIn || state is Suspended;
return MaterialApp(
navigatorKey: _navigator,
scaffoldMessengerKey: _messenger,
title: 'IM 示例',
home: signedIn ? ConversationsPage(im: im) : LoginPage(im: im),
);
}
}
class LoginPage extends StatefulWidget {
const LoginPage({super.key, required this.im});
final DRClient im;
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
bool _busy = false;
String? _error;
Future<void> _login() async {
setState(() {
_busy = true;
_error = null;
});
try {
await widget.im.auth.loginWithTicket(ticket: await fetchTicket());
} on DRException catch (e) {
setState(() => _error = describeError(e));
} catch (e) {
setState(() => _error = '$e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('登录')),
body: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
if (_error != null) Text(_error!, style: const TextStyle(color: Colors.red)),
FilledButton(onPressed: _busy ? null : () => unawaited(_login()), child: const Text('登录 IM')),
]),
),
);
}
class ConversationsPage extends StatefulWidget {
const ConversationsPage({super.key, required this.im});
final DRClient im;
@override
State<ConversationsPage> createState() => _ConversationsPageState();
}
class _ConversationsPageState extends State<ConversationsPage> {
late final ConversationList _list = widget.im.conversations.list();
final _peer = TextEditingController();
DRClient get im => widget.im;
@override
void dispose() {
_list.dispose();
_peer.dispose();
super.dispose();
}
void _open(ConversationView c) {
unawaited(Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => ChatPage(im: im, conversationKey: c.conversationKey, title: conversationTitle(im, c)))));
}
Future<void> _startChat() async {
final username = _peer.text.trim();
if (username.isEmpty) return;
_open(await im.conversations.openSingle(username));
}
@override
Widget build(BuildContext context) {
final me = im.auth.currentUser?.username;
return Scaffold(
appBar: AppBar(
title: StreamBuilder<DRConnectionState>(
stream: im.connection.stateStream,
initialData: im.connection.state,
builder: (context, s) => Text(switch (s.data) {
ConnectionReady() => '$me',
ConnectionOffline() => '网络不可用',
_ => '连接中…',
}),
),
actions: [IconButton(icon: const Icon(Icons.logout), onPressed: () => unawaited(im.auth.logout()))],
),
body: Column(children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(children: [
Expanded(child: TextField(controller: _peer, decoration: const InputDecoration(labelText: '对方的用户名'))),
TextButton(onPressed: () => unawaited(_startChat()), child: const Text('开始聊天')),
]),
),
Expanded(
child: LiveListBuilder<ConversationView>(
list: _list,
builder: (context, snapshot) => ListView.builder(
itemCount: snapshot.items.length,
itemBuilder: (context, i) {
final c = snapshot.items[i];
final last = c.lastMessage;
return ListTile(
key: ValueKey(c.conversationKey),
title: Text(conversationTitle(im, c)),
subtitle: Text(last == null ? '' : summarize(last, nameOf: (u) => displayName(im, u), self: me), maxLines: 1),
trailing: c.unreadCount > 0 ? Badge(label: Text('${c.unreadCount}')) : null,
onTap: () => _open(c),
);
},
),
),
),
]),
);
}
}
class ChatPage extends StatefulWidget {
const ChatPage({super.key, required this.im, required this.conversationKey, required this.title});
final DRClient im;
final String conversationKey;
final String title;
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
late final MessageList _list = widget.im.messages.open(widget.conversationKey);
final _input = TextEditingController();
DRClient get im => widget.im;
@override
void initState() {
super.initState();
_list.setViewing(visible: true, atBottom: true);
_list.markRead();
}
@override
void dispose() {
_list.dispose();
_input.dispose();
super.dispose();
}
Future<void> _send() async {
final text = _input.text.trim();
if (text.isEmpty) return;
_input.clear();
try {
// 草稿会话发出第一条消息后会换成正式的会话,_list.conversationKey 随之更新
await im.messages.send(SendTarget.conversation(_list.conversationKey), OutgoingContent.text(text));
} on DRException catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(describeError(e))));
}
}
@override
Widget build(BuildContext context) {
final me = im.auth.currentUser?.username;
return Scaffold(
appBar: AppBar(title: Text(widget.title)),
body: Column(children: [
Expanded(
child: StreamBuilder<MessageListSnapshot>(
stream: _list.stream,
initialData: _list.snapshot,
builder: (context, s) {
final items = s.data?.items ?? const <MessageView>[];
return ListView.builder(
reverse: true, // 最新的消息在底部
itemCount: items.length,
itemBuilder: (context, i) {
final m = items[items.length - 1 - i];
final mine = m.sender == me;
final text = m.type == 'text' ? '${m.body?['text'] ?? ''}' : summarize(m, nameOf: (u) => displayName(im, u), self: me);
final status = m.local.status == 'sent' ? '' : '(${m.local.status})';
return Align(
key: ValueKey(m.clientMsgId ?? m.messageId),
alignment: mine ? Alignment.centerRight : Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Text(mine ? '$text$status' : '${displayName(im, m.sender)}:$text'),
),
);
},
);
},
),
),
SafeArea(
child: Row(children: [
Expanded(child: Padding(padding: const EdgeInsets.all(8), child: TextField(controller: _input, onSubmitted: (_) => unawaited(_send())))),
IconButton(icon: const Icon(Icons.send), onPressed: () => unawaited(_send())),
]),
),
]),
);
}
}這個示例中:
- 登入、退出、會話結束時
im.auth.stateStream發出新的狀態,示例據此在登入頁和會話列表之間切換;已登入過的 App 啟動後直接顯示會話列表; - 會話列表、訊息列表的顯示都只依賴列表的快照,收到新訊息、傳送狀態變化、其他裝置上的已讀等都會自動反映出來;
- 開啟著的聊天頁透過
setViewing報告使用者正在看,收到新訊息後呼叫markRead()或開啟autoMarkRead選項即可自動標為已讀,見初始化、登入與連線。
下一步
- 初始化、登入與連線:全部初始化選項、密碼登入、會話結束的原因、連線狀態與前後臺;
- 會話、訊息:未讀數、草稿、圖片和檔案訊息、撤回、已讀回執等;
- 在 Flutter 介面中使用:
LiveListBuilder、DRImage、錄音; - 離線推送、音視訊通話:推送和系統來電介面的接入;
- 事件與錯誤處理:全部事件和錯誤碼。
接入獨立頻道
登入之後,可以按頻道接入加入業務服務端建立的頻道。前提是應用已開通頻道,並且業務服務端已允許這個使用者加入;加入時預設不開啟攝像頭和麥克風。
