在 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 等時做法相同:客戶端是全域性唯一的物件,列表和訂閱跟隨頁面建立和釋放。
| 要做什麼 | 看哪裡 |
|---|---|
| 讓各頁面拿到客戶端 | 客戶端放在哪裡 |
| 按登入狀態切換登入頁和主介面,顯示連線提示條 | 登入狀態與連線狀態 |
| 顯示會話、好友、群成員列表 | 列表:LiveList 與 LiveListBuilder |
| 顯示訊息列表 | 訊息列表 |
| 顯示未讀數、正在輸入,彈出傳送失敗的提示 | 訂閱事件和狀態 |
| 頁面被蓋住、切到其他標籤時的處理 | 生命週期 |
客戶端放在哪裡
客戶端在 main() 中 runApp 之前建立一次,整個 App 共用,不要在 Widget 中建立。客戶端與程序同生命週期,不需要釋放;對同一個 AppKey 再次呼叫 DRClient.create() 返回同一個客戶端。
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_flutter/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_flutter/render.dart 的 conversationTitle(im, conversation)、displayName(im, username, groupId: ...) 按備註、群暱稱、暱稱、使用者名稱的順序取名稱;要讓名稱跟著變化,在 UsersChanged、FriendsChanged、GroupsChanged 時重建:
import 'package:deeprespond_im_flutter/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? | 最近一次載入的錯誤 |
頻道影片元件
deeprespond_im_call 提供 ChannelVideoView,頻道控制代碼上還有 localView()、remoteView(sessionId)、switchCamera()、setSpeakerphoneOn()。
- 遠端影片按軌道的
sessionId(字串)繫結,不要用使用者名稱或數字 ID。 - 移除影片元件只是不再顯示。頁面退出時要取消對快照和軌道的訂閱,並
await channel.leave()。 - 訂閱
stream和tracks時會先收到一次當前的值。
示例見獨立頻道。
