在 Flutter 界面中使用
Flutter SDK 的核心包 deeprespond_im 是纯 Dart 的,不依赖 Flutter:会变化的数据都以“快照 + Stream”的形式提供。deeprespond_im_flutter 在此之上提供几个界面上常用的部件:
| 部件 | 用途 |
|---|---|
LiveListBuilder | 订阅一个 LiveList(会话列表、好友列表等),快照变化时重建 |
toValueListenable() | 把 LiveList 转为 Flutter 的 ValueListenable,可以交给 ValueListenableBuilder |
DRImage | 显示消息中的图片、头像,自动换取下载地址并缓存,见文件与图片 |
本页说明客户端放在哪里、怎样把列表、事件和状态接到 Widget 上,以及在 Widget 的生命周期中要注意什么。示例只用 Flutter 自带的 StatefulWidget、InheritedWidget、StreamBuilder,不依赖任何状态管理包;使用 Provider、Riverpod、Bloc 等时做法相同:客户端是全局唯一的对象,列表和订阅跟随页面创建和释放。
客户端放在哪里
客户端在 main() 中 runApp 之前创建一次,整个 App 共用,不要在 Widget 中创建。客户端与进程同生命周期,不需要释放;对同一个 AppKey 再次调用 DRClient.create() 返回同一个客户端。
import 'package:deeprespond_im/deeprespond_im.dart';
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
import 'package:flutter/material.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); // 在使用 DRFlutterPlatform 之前
final im = await DRClient.create(DRClientOptions(
appKey: '1575529652#demo',
apiUrl: Uri.parse('https://im.example.com'),
platform: DRFlutterPlatform.instance,
));
runApp(IMScope(client: im, child: const MyApp()));
}
/// 在 Widget 树中提供客户端:任何子孙 Widget 用 IMScope.of(context) 取得。
class IMScope extends InheritedWidget {
const IMScope({super.key, required this.client, required super.child});
final DRClient client;
static DRClient of(BuildContext context) => context.dependOnInheritedWidgetOfExactType<IMScope>()!.client;
@override
bool updateShouldNotify(IMScope oldWidget) => !identical(client, oldWidget.client);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(home: Placeholder());
}也可以像示例 App 那样把 im 作为构造参数逐层传下去,或交给你使用的依赖注入、状态管理包(如 Provider 的 Provider<DRClient>.value(value: im))。本页后面的示例都用构造参数传入的 im。
热重载(hot reload)不会重新执行 main(),客户端保持不变;热重启(hot restart)会重新创建客户端,SDK 已处理,不需要另外做什么。
登录状态与连接状态
根据登录状态决定显示登录页还是主界面。im.auth.state 是当前的状态,im.auth.stateStream 发出之后的变化,交给 StreamBuilder 时用 initialData 给出当前值:
import 'package:flutter/material.dart';
class Root extends StatefulWidget {
const Root({super.key, required this.im});
final DRClient im;
@override
State<Root> createState() => _RootState();
}
class _RootState extends State<Root> {
// 在 State 中保存 Stream,不要在 build 中每次重新取得
late final Stream<AuthState> _auth = widget.im.auth.stateStream;
@override
Widget build(BuildContext context) => StreamBuilder<AuthState>(
stream: _auth,
initialData: widget.im.auth.state,
builder: (context, s) => switch (s.data) {
// 换用户时用新的 key,旧用户的页面(和其中的列表)整体释放
SignedIn() || Suspended() => HomePage(key: ValueKey(widget.im.auth.currentUser?.username), im: widget.im),
_ => const LoginPage(),
},
);
}连接状态同样处理,用来显示“连接中”“网络不可用”的提示条:
import 'package:flutter/material.dart';
class ConnectionBanner extends StatefulWidget {
const ConnectionBanner({super.key, required this.im});
final DRClient im;
@override
State<ConnectionBanner> createState() => _ConnectionBannerState();
}
class _ConnectionBannerState extends State<ConnectionBanner> {
late final Stream<DRConnectionState> _states = widget.im.connection.stateStream;
@override
Widget build(BuildContext context) => StreamBuilder<DRConnectionState>(
stream: _states,
initialData: widget.im.connection.state,
builder: (context, s) => switch (s.data) {
ConnectionReady() || null => const SizedBox.shrink(),
ConnectionOffline() => const MaterialBanner(content: Text('网络不可用'), actions: [SizedBox.shrink()]),
_ => const MaterialBanner(content: Text('连接中…'), actions: [SizedBox.shrink()]),
},
);
}登录、连接的状态与处理见初始化、登录与连接。
列表:LiveList 与 LiveListBuilder
本地有完整数据、需要持续更新的列表都是 LiveList:
| 方法 | 列表 |
|---|---|
im.conversations.list() | 会话列表(ConversationList) |
im.friends.list() | 好友 |
im.friends.blacklist.list() | 黑名单 |
im.groups.list() | 我的群 |
im.groups.members(groupId, role: ..., muted: ...) | 群成员 |
每次调用都创建一个新的列表。列表由创建它的一方负责释放:在 State 中创建(initState 或 late final 字段),在 dispose() 中调用 list.dispose()。LiveListBuilder 只订阅、不释放。
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
import 'package:flutter/material.dart';
class ConversationsPage extends StatefulWidget {
const ConversationsPage({super.key, required this.im, required this.onOpen});
final DRClient im;
final void Function(String conversationKey) onOpen;
@override
State<ConversationsPage> createState() => _ConversationsPageState();
}
class _ConversationsPageState extends State<ConversationsPage> {
late final ConversationList _list = widget.im.conversations.list();
@override
void dispose() {
_list.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => LiveListBuilder<ConversationView>(
list: _list,
builder: (context, snapshot) {
if (snapshot.loading && snapshot.items.isEmpty) return const Center(child: CircularProgressIndicator());
return ListView.builder(
itemCount: snapshot.items.length + (snapshot.hasMore ? 1 : 0),
itemBuilder: (context, i) {
if (i == snapshot.items.length) {
return TextButton(onPressed: () => unawaited(_list.loadMore()), child: const Text('加载更多'));
}
final c = snapshot.items[i];
return ListTile(
key: ValueKey(c.conversationKey),
title: Text(c.peer ?? c.groupId ?? c.conversationKey),
trailing: c.unreadCount > 0 ? Badge(label: Text('${c.unreadCount}')) : null,
onTap: () => widget.onOpen(c.conversationKey),
);
},
);
},
);
}- 快照
LiveListSnapshot有items、hasMore、loading、error。快照和其中的条目不可修改;没有变化的条目沿用原来的对象,可以按引用判断是否需要重建。 - 同一轮事件循环中的多次变化合并为一次通知;没有变化时不发出新快照。
loadMore()加载下一页,hasMore为false时不需要调用。- 参数变化时(如群成员页的
groupId变了),在didUpdateWidget中释放旧的列表、创建新的。
不用 LiveListBuilder 时,可以直接用 StreamBuilder(stream: list.stream、initialData: list.snapshot),或用 toValueListenable() 转为 ValueListenable。后者是一个 ChangeNotifier,要和列表一起释放:
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class FriendsPage extends StatefulWidget {
const FriendsPage({super.key, required this.im});
final DRClient im;
@override
State<FriendsPage> createState() => _FriendsPageState();
}
class _FriendsPageState extends State<FriendsPage> {
late final LiveList<Friend> _list = widget.im.friends.list();
late final ValueListenable<LiveListSnapshot<Friend>> _friends = _list.toValueListenable();
@override
void dispose() {
(_friends as ChangeNotifier).dispose(); // 先取消订阅
_list.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ValueListenableBuilder<LiveListSnapshot<Friend>>(
valueListenable: _friends,
builder: (context, snapshot, _) => ListView(children: [for (final f in snapshot.items) ListTile(title: Text(f.username))]),
);
}消息列表
一个会话的消息列表 MessageList(im.messages.open(conversationKey) 打开)不是 LiveList:它可以向前、向后翻页,有空洞和置顶消息,所以不能交给 LiveListBuilder,也没有 toValueListenable()。用 StreamBuilder 订阅它的 stream,同样由创建它的页面在 dispose() 中释放:
import 'package:flutter/material.dart';
class ChatPage extends StatefulWidget {
const ChatPage({super.key, required this.im, required this.conversationKey});
final DRClient im;
final String conversationKey;
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
late final MessageList _list = widget.im.messages.open(widget.conversationKey);
@override
void dispose() {
_list.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => StreamBuilder<MessageListSnapshot>(
stream: _list.stream,
initialData: _list.snapshot,
builder: (context, s) {
final snapshot = s.data ?? _list.snapshot;
final items = snapshot.items;
return ListView.builder(
reverse: true, // 最新的在下面
itemCount: items.length,
itemBuilder: (context, i) {
final m = items[items.length - 1 - i];
if (i == items.length - 1 && snapshot.hasOlder) unawaited(_list.loadOlder()); // 滚到最上面时加载更早的
return ListTile(key: ValueKey(m.clientMsgId ?? '${m.seq}'), title: Text('${m.body?['text'] ?? '[${m.type}]'}'));
},
);
},
);
}消息列表的快照、翻页、已读和发送见消息。
订阅事件和状态
用 StreamBuilder 显示一个值
只需显示一个随事件变化的值(未读总数、正在输入的人、某人的在线状态)时,用 StreamBuilder 订阅对应的事件,在 builder 中读取当前值。下面显示“对方正在输入”:
import 'package:flutter/material.dart';
class TypingHint extends StatefulWidget {
const TypingHint({super.key, required this.im, required this.conversationKey});
final DRClient im;
final String conversationKey;
@override
State<TypingHint> createState() => _TypingHintState();
}
class _TypingHintState extends State<TypingHint> {
late final Stream<TypingChanged> _changes = widget.im.on<TypingChanged>().where((e) => e.conversationKey == widget.conversationKey);
@override
Widget build(BuildContext context) => StreamBuilder<TypingChanged>(
stream: _changes,
builder: (context, _) {
final typing = widget.im.conversations.getTyping(widget.conversationKey); // 每次重建时读取当前值
return typing.isEmpty ? const SizedBox.shrink() : const Text('对方正在输入…');
},
);
}哪些值可以同步读取、变化时发出哪个事件,见事件与错误处理。
在 State 中订阅
要在事件到来时做事(弹出提示、跳转页面)时,在 initState 中 listen,在 dispose() 中取消:
import 'dart:async';
import 'package:deeprespond_im/render.dart';
import 'package:flutter/material.dart';
class SendFailureListener extends StatefulWidget {
const SendFailureListener({super.key, required this.im, required this.child});
final DRClient im;
final Widget child;
@override
State<SendFailureListener> createState() => _SendFailureListenerState();
}
class _SendFailureListenerState extends State<SendFailureListener> {
late final StreamSubscription<MessageSendFailed> _sub;
@override
void initState() {
super.initState();
_sub = widget.im.on<MessageSendFailed>().listen((e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(describeError(e.error))));
});
}
@override
void dispose() {
unawaited(_sub.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}全部事件见事件与错误处理。聊天室和通话的对象另有自己的 stream,用法相同,见聊天室和音视频通话。
名称的显示
会话标题和用户的显示名称不在会话对象中:它们来自好友备注、群昵称、用户资料和群名,这些变化时会话对象不变。package:deeprespond_im/render.dart 的 conversationTitle(im, conversation)、displayName(im, username, groupId: ...) 按备注、群昵称、昵称、用户名的顺序取名称;要让名称跟着变化,在 UsersChanged、FriendsChanged、GroupsChanged 时重建:
import 'package:deeprespond_im/render.dart';
import 'package:flutter/material.dart';
class ConversationTitle extends StatefulWidget {
const ConversationTitle({super.key, required this.im, required this.conversation});
final DRClient im;
final ConversationView conversation;
@override
State<ConversationTitle> createState() => _ConversationTitleState();
}
class _ConversationTitleState extends State<ConversationTitle> {
late final Stream<DREvent> _names = widget.im.events.where((e) => e is UsersChanged || e is FriendsChanged || e is GroupsChanged);
@override
Widget build(BuildContext context) => StreamBuilder<DREvent>(
stream: _names,
builder: (context, _) => Text(conversationTitle(widget.im, widget.conversation)),
);
}生命周期
- 前后台:
DRFlutterPlatform自己监听 App 的前后台切换和网络变化,SDK 据此暂停或恢复长连接、上报前后台状态、回到前台时同步。App 不需要转发AppLifecycleState。 - 释放:列表、
StreamSubscription、toValueListenable()得到的对象都在State.dispose()中释放。没有释放的消息列表会让 SDK 继续为它补齐消息,还会让这个会话一直算作“正在被看”。 - 退出与切换用户:打开着的列表会被清空(发出一次空快照)并释放,之后不再更新。请在登录后重新创建页面,最简单的做法是像上文那样以用户名作为主界面的
key。对已释放的列表再调用dispose()没有影响,调用loadMore()等方法以invalid_state(disposed)拒绝。 - 看不见的消息列表:SDK 只在 App 位于前台、列表在显示并停在最新处时才认为用户正在看这个会话(开启
autoMarkRead时据此自动标记已读)。App 切到后台由 SDK 自动处理;页面被新的路由盖住、在TabBarView中被切走而没有释放时,调用list.setViewing(visible: false),回来时再设为true;用户向上翻看历史消息时调用list.setViewing(atBottom: false)。
被新路由盖住时,可以用 Flutter 的 RouteAware 报告:
import 'package:flutter/material.dart';
// MaterialApp(navigatorObservers: [routeObserver], ...) 中登记同一个 routeObserver
class ChatView extends StatefulWidget {
const ChatView({super.key, required this.im, required this.conversationKey});
final DRClient im;
final String conversationKey;
@override
State<ChatView> createState() => _ChatViewState();
}
class _ChatViewState extends State<ChatView> with RouteAware {
late final MessageList _list = widget.im.messages.open(widget.conversationKey);
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route != null) routeObserver.subscribe(this, route);
}
@override
void didPushNext() => _list.setViewing(visible: false); // 被新的页面盖住
@override
void didPopNext() => _list.setViewing(visible: true); // 回到这个页面
@override
void dispose() {
routeObserver.unsubscribe(this);
_list.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => const SizedBox();
}注意事项
- 不在 build 中创建列表:
im.conversations.list()、im.messages.open()等每次调用都创建新的列表,要在State中创建、在dispose()中释放。 - Stream 保存在 State 中:
im.on<T>()、im.auth.stateStream、list.stream每次读取都返回新的Stream对象,StreamBuilder发现stream换了就会重新订阅。直接写在build中也能正确显示(重新订阅后仍保留上一次的数据,列表的stream订阅时先发出当前快照),但父组件频繁重建时会反复订阅,建议像本页的示例一样保存在State的字段中。 - 只在主 isolate 中使用:
DRClient和它的列表、事件都在主 isolate 中。推送、来电的后台处理函数运行在后台 isolate 中,要用DRBackgroundClient,见离线推送。 - 长列表:会话列表最多有
conversationWindow(默认 1000)项,消息列表随翻页增长,请用ListView.builder等按需构建的列表,并以conversationKey、clientMsgId或seq作为条目的key。 - 图片:消息中的图片、群文件的缩略图用
DRImage,不要把文件地址直接交给Image.network(文件地址不能直接加载),也不要保存换取得到的下载地址,见文件与图片。 - 一个进程只有一个平台实现:
DRFlutterPlatform.instance在第一次使用时创建,之前要调用WidgetsFlutterBinding.ensureInitialized()。
接口参考
LiveListBuilder
deeprespond_im_flutter 的 StatelessWidget。订阅一个 LiveList,快照变化时调用 builder 重建;不释放列表。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
list | LiveList<T> | 是(命名参数) | 要订阅的列表 |
builder | Widget Function(BuildContext context, LiveListSnapshot<T> snapshot) | 是(命名参数) | 按快照构建界面;第一次构建时为列表的当前快照 |
key | Key? | 否(命名参数) |
LiveList.toValueListenable()
deeprespond_im_flutter 为 LiveList 提供的扩展方法,导入 package:deeprespond_im_flutter/deeprespond_im_flutter.dart 后可用。
返回值:ValueListenable<LiveListSnapshot<T>>,value 为列表的当前快照,快照变化时通知。它同时是一个 ChangeNotifier,不再使用时调用 dispose() 取消对列表的订阅(不释放列表)。
数据结构
LiveList
| 成员 | 类型 | 说明 |
|---|---|---|
snapshot | LiveListSnapshot<T> | 当前快照 |
stream | Stream<LiveListSnapshot<T>> | 订阅时先发出当前快照,之后每次变化发出新快照;列表释放后结束 |
loadMore() | Future<void> | 加载下一页 |
dispose() | void | 释放列表,之后不再更新;可以重复调用 |
LiveListSnapshot
| 字段 | 类型 | 说明 |
|---|---|---|
items | List<T> | 列表的条目,不可修改 |
hasMore | bool | 是否还有下一页 |
loading | bool | 是否正在加载(第一次打开时为 true) |
error | DRException? | 最近一次加载的错误 |
频道视频组件
媒体包新增 ChannelVideoView,以及 ChannelHandle 的 localView / remoteView(sessionId)、switchCamera、setSpeakerphoneOn 扩展。远端视图按字符串 sessionId 绑定授权轨道,不使用用户名或数值 UID。
Widget 移除只解绑渲染;页面退出应取消快照 / 轨道订阅,并主动 await channel.leave。快照和轨道流回放当前值,示例见独立频道。
