快速集成
本页从零开始做一个能收发消息的 Flutter App:完成平台配置、创建客户端、用业务服务端签发的凭证登录、显示会话列表、打开会话查看消息、发送文本消息、收到新消息时提醒、退出登录。完成后你会得到一个可以直接运行的示例,可以在它的基础上开发自己的界面。
准备工作
- 在控制台创建应用,在应用详情中记下 AppKey(如
1575529652#demo)和 IM 服务的地址(如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 deeprespond_im_flutter本页只用到核心包和 Flutter 接入包。需要离线推送、音视频通话时再添加 deeprespond_im_push、deeprespond_im_call,见概述。
平台配置
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/deeprespond_im.dart';
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/deeprespond_im.dart';
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/deeprespond_im.dart';
import 'package:deeprespond_im/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/deeprespond_im.dart';
import 'package:deeprespond_im/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、录音; - 离线推送、音视频通话:推送和系统来电界面的接入;
- 事件与错误处理:全部事件和错误码。
接入独立频道
完成客户端初始化与登录后,可以按频道接入加入业务服务端创建的频道。加入需要应用频道开通和目标用户授权;默认不采集。
