Firestoreを使用するFlutterにチャットアプリがあり、2つの主要なコレクションがあります。
chats
。auto-idにキーが付けられ、message
、timestamp
、およびuid
フィールドがあります。users
はuid
をキーとし、name
フィールドがあります私のアプリでは、次のウィジェットで(messages
コレクションからの)メッセージのリストを表示します。
class ChatList extends StatelessWidget {
@override
Widget build(BuildContext context) {
var messagesSnapshot = Firestore.instance.collection("chat").orderBy("timestamp", descending: true).snapshots();
var streamBuilder = StreamBuilder<QuerySnapshot>(
stream: messagesSnapshot,
builder: (BuildContext context, AsyncSnapshot<QuerySnapshot> querySnapshot) {
if (querySnapshot.hasError)
return new Text('Error: ${querySnapshot.error}');
switch (querySnapshot.connectionState) {
case ConnectionState.waiting: return new Text("Loading...");
default:
return new ListView(
children: querySnapshot.data.documents.map((DocumentSnapshot doc) {
return new ListTile(
title: new Text(doc['message']),
subtitle: new Text(DateTime.fromMillisecondsSinceEpoch(doc['timestamp']).toString()),
);
}).toList()
);
}
}
);
return streamBuilder;
}
}
しかし、今度は各メッセージの(users
コレクションからの)ユーザー名を表示したいと思います。
Flutterに特定の名前があるかどうかはわかりませんが、私は通常、これをクライアント側の結合と呼びます。
私はこれを行う1つの方法を見つけましたが(これは以下に投稿します)、Flutterでこのタイプの操作を行う別の/より良い/より慣用的な方法があるかどうか疑問に思います。
それで、上記の構造で各メッセージのユーザー名を検索するFlutterの慣用的な方法は何ですか?
2つのネストされたビルダーでの私の答え よりも少し良いように見える別のバージョンが動作しました。
ここでは、専用のMessage
クラスを使用してメッセージDocument
とオプションの関連ユーザーDocument
からの情報を保持する、カスタムメソッドでのデータの読み込みを分離しました。
class Message {
final message;
final timestamp;
final uid;
final user;
const Message(this.message, this.timestamp, this.uid, this.user);
}
class ChatList extends StatelessWidget {
Stream<List<Message>> getData() async* {
var messagesStream = Firestore.instance.collection("chat").orderBy("timestamp", descending: true).snapshots();
var messages = List<Message>();
await for (var messagesSnapshot in messagesStream) {
for (var messageDoc in messagesSnapshot.documents) {
var message;
if (messageDoc["uid"] != null) {
var userSnapshot = await Firestore.instance.collection("users").document(messageDoc["uid"]).get();
message = Message(messageDoc["message"], messageDoc["timestamp"], messageDoc["uid"], userSnapshot["name"]);
}
else {
message = Message(messageDoc["message"], messageDoc["timestamp"], "", "");
}
messages.add(message);
}
yield messages;
}
}
@override
Widget build(BuildContext context) {
var streamBuilder = StreamBuilder<List<Message>>(
stream: getData(),
builder: (BuildContext context, AsyncSnapshot<List<Message>> messagesSnapshot) {
if (messagesSnapshot.hasError)
return new Text('Error: ${messagesSnapshot.error}');
switch (messagesSnapshot.connectionState) {
case ConnectionState.waiting: return new Text("Loading...");
default:
return new ListView(
children: messagesSnapshot.data.map((Message msg) {
return new ListTile(
title: new Text(msg.message),
subtitle: new Text(DateTime.fromMillisecondsSinceEpoch(msg.timestamp).toString()
+"\n"+(msg.user ?? msg.uid)),
);
}).toList()
);
}
}
);
return streamBuilder;
}
}
ネストされたビルダーによるソリューション と比較して、このコードはより読みやすくなっています。これは、主にデータ処理とUIビルダーがより適切に分離されているためです。また、メッセージを投稿したユーザーのユーザードキュメントのみを読み込みます。残念ながら、ユーザーが複数のメッセージを投稿した場合、メッセージごとにドキュメントが読み込まれます。キャッシュを追加することもできますが、このコードはそれが達成することについてはすでに少し長いと思います。
そのようなRxDartでそれを行うことができます。 https://pub.dev/packages/rxdart
import 'package:rxdart/rxdart.Dart';
class Messages {
final String messages;
final DateTime timestamp;
final String uid;
final DocumentReference reference;
Messages.fromMap(Map<String, dynamic> map, {this.reference})
: messages = map['messages'],
timestamp = (map['timestamp'] as Timestamp)?.toDate(),
uid = map['uid'];
Messages.fromSnapshot(DocumentSnapshot snapshot)
: this.fromMap(snapshot.data, reference: snapshot.reference);
@override
String toString() {
return 'Messages{messages: $messages, timestamp: $timestamp, uid: $uid, reference: $reference}';
}
}
class Users {
final String name;
final DocumentReference reference;
Users.fromMap(Map<String, dynamic> map, {this.reference})
: name = map['name'];
Users.fromSnapshot(DocumentSnapshot snapshot)
: this.fromMap(snapshot.data, reference: snapshot.reference);
@override
String toString() {
return 'Users{name: $name, reference: $reference}';
}
}
class CombineStream {
final Messages messages;
final Users users;
CombineStream(this.messages, this.users);
}
Stream<List<CombineStream>> _combineStream;
@override
void initState() {
super.initState();
_combineStream = Observable(Firestore.instance
.collection('chat')
.orderBy("timestamp", descending: true)
.snapshots())
.map((convert) {
return convert.documents.map((f) {
Stream<Messages> messages = Observable.just(f)
.map<Messages>((document) => Messages.fromSnapshot(document));
Stream<Users> user = Firestore.instance
.collection("users")
.document(f.data['uid'])
.snapshots()
.map<Users>((document) => Users.fromSnapshot(document));
return Observable.combineLatest2(
messages, user, (messages, user) => CombineStream(messages, user));
});
}).switchMap((observables) {
return observables.length > 0
? Observable.combineLatestList(observables)
: Observable.just([]);
})
}
rxdart 0.23.xの場合
@override
void initState() {
super.initState();
_combineStream = Firestore.instance
.collection('chat')
.orderBy("timestamp", descending: true)
.snapshots()
.map((convert) {
return convert.documents.map((f) {
Stream<Messages> messages = Stream.value(f)
.map<Messages>((document) => Messages.fromSnapshot(document));
Stream<Users> user = Firestore.instance
.collection("users")
.document(f.data['uid'])
.snapshots()
.map<Users>((document) => Users.fromSnapshot(document));
return Rx.combineLatest2(
messages, user, (messages, user) => CombineStream(messages, user));
});
}).switchMap((observables) {
return observables.length > 0
? Rx.combineLatestList(observables)
: Stream.value([]);
})
}
これを正しく読んでいる場合、問題は次のように要約されます。ストリーム内のデータを変更するために非同期呼び出しを行う必要があるデータストリームをどのように変換しますか?
問題のコンテキストでは、データのストリームはメッセージのリストであり、非同期呼び出しは、ユーザーデータをフェッチして、ストリーム内のこのデータでメッセージを更新することです。
asyncMap()
関数を使用して、Dartストリームオブジェクトで直接これを行うことができます。以下に、その方法を示す純粋なDartコードをいくつか示します。
_import 'Dart:async';
import 'Dart:math' show Random;
final random = Random();
const messageList = [
{
'message': 'Message 1',
'timestamp': 1,
'uid': 1,
},
{
'message': 'Message 2',
'timestamp': 2,
'uid': 2,
},
{
'message': 'Message 3',
'timestamp': 3,
'uid': 2,
},
];
const userList = {
1: 'User 1',
2: 'User 2',
3: 'User 3',
};
class Message {
final String message;
final int timestamp;
final int uid;
final String user;
const Message(this.message, this.timestamp, this.uid, this.user);
@override
String toString() => '$user => $message';
}
// Mimic a stream of a list of messages
Stream<List<Map<String, dynamic>>> getServerMessagesMock() async* {
yield messageList;
while (true) {
await Future.delayed(Duration(seconds: random.nextInt(3) + 1));
yield messageList;
}
}
// Mimic asynchronously fetching a user
Future<String> userMock(int uid) => userList.containsKey(uid)
? Future.delayed(
Duration(milliseconds: 100 + random.nextInt(100)),
() => userList[uid],
)
: Future.value(null);
// Transform the contents of a stream asynchronously
Stream<List<Message>> getMessagesStream() => getServerMessagesMock()
.asyncMap<List<Message>>((messageList) => Future.wait(
messageList.map<Future<Message>>(
(m) async => Message(
m['message'],
m['timestamp'],
m['uid'],
await userMock(m['uid']),
),
),
));
void main() async {
print('Streams with async transforms test');
await for (var messages in getMessagesStream()) {
messages.forEach(print);
}
}
_
ほとんどのコードは、Firebaseからのデータをメッセージのマップのストリームとして模倣し、非同期関数を使用してユーザーデータを取得しています。ここで重要な関数はgetMessagesStream()
です。
ストリームに入ってくるメッセージのリストであるため、コードは少し複雑です。ユーザーデータをフェッチする呼び出しが同期的に発生しないようにするために、コードはFuture.wait()
を使用して_List<Future<Message>>
_を収集し、すべてのFutureが完了したときに_List<Message>
_を作成します。
Flutterのコンテキストでは、FutureBuilder
のgetMessagesStream()
からのストリームを使用して、メッセージオブジェクトを表示できます。
理想的には、別のサービスへのデータのロードやBloCパターンに従うなどのビジネスロジックを除外する必要があります。
class ChatBloc {
final Firestore firestore = Firestore.instance;
final Map<String, String> userMap = HashMap<String, String>();
Stream<List<Message>> get messages async* {
final messagesStream = Firestore.instance.collection('chat').orderBy('timestamp', descending: true).snapshots();
var messages = List<Message>();
await for (var messagesSnapshot in messagesStream) {
for (var messageDoc in messagesSnapshot.documents) {
final userUid = messageDoc['uid'];
var message;
if (userUid != null) {
// get user data if not in map
if (userMap.containsKey(userUid)) {
message = Message(messageDoc['message'], messageDoc['timestamp'], userUid, userMap[userUid]);
} else {
final userSnapshot = await Firestore.instance.collection('users').document(userUid).get();
message = Message(messageDoc['message'], messageDoc['timestamp'], userUid, userSnapshot['name']);
// add entry to map
userMap[userUid] = userSnapshot['name'];
}
} else {
message =
Message(messageDoc['message'], messageDoc['timestamp'], '', '');
}
messages.add(message);
}
yield messages;
}
}
}
次に、コンポーネントでBlocを使用して、chatBloc.messages
ストリーム。
class ChatList extends StatelessWidget {
final ChatBloc chatBloc = ChatBloc();
@override
Widget build(BuildContext context) {
return StreamBuilder<List<Message>>(
stream: chatBloc.messages,
builder: (BuildContext context, AsyncSnapshot<List<Message>> messagesSnapshot) {
if (messagesSnapshot.hasError)
return new Text('Error: ${messagesSnapshot.error}');
switch (messagesSnapshot.connectionState) {
case ConnectionState.waiting:
return new Text('Loading...');
default:
return new ListView(children: messagesSnapshot.data.map((Message msg) {
return new ListTile(
title: new Text(msg.message),
subtitle: new Text('${msg.timestamp}\n${(msg.user ?? msg.uid)}'),
);
}).toList());
}
});
}
}
私のRxDartソリューションのバージョンを公開できるようにします。 combineLatest2
とListView.builder
を使用して、各メッセージウィジェットを作成します。各メッセージウィジェットの作成中に、対応するuid
を使用してユーザーの名前を検索します。
このスニペットでは、ユーザー名に線形ルックアップを使用していますが、uid -> user name
マップを作成することで改善できます
import 'package:cloud_firestore/cloud_firestore.Dart';
import 'package:flutter/widgets.Dart';
import 'package:rxdart/rxdart.Dart';
class MessageWidget extends StatelessWidget {
// final chatStream = Firestore.instance.collection('chat').snapshots();
// final userStream = Firestore.instance.collection('users').snapshots();
Stream<QuerySnapshot> chatStream;
Stream<QuerySnapshot> userStream;
MessageWidget(this.chatStream, this.userStream);
@override
Widget build(BuildContext context) {
Observable<List<QuerySnapshot>> combinedStream = Observable.combineLatest2(
chatStream, userStream, (messages, users) => [messages, users]);
return StreamBuilder(
stream: combinedStream,
builder: (_, AsyncSnapshot<List<QuerySnapshot>> snapshots) {
if (snapshots.hasData) {
List<DocumentSnapshot> chats = snapshots.data[0].documents;
// It would be more efficient to convert this list of user documents
// to a map keyed on the uid which will allow quicker user lookup.
List<DocumentSnapshot> users = snapshots.data[1].documents;
return ListView.builder(itemBuilder: (_, index) {
return Center(
child: Column(
children: <Widget>[
Text(chats[index]['message']),
Text(getUserName(users, chats[index]['uid'])),
],
),
);
});
} else {
return Text('loading...');
}
});
}
// This does a linear search through the list of users. However a map
// could be used to make the finding of the user's name more efficient.
String getUserName(List<DocumentSnapshot> users, String uid) {
for (final user in users) {
if (user['uid'] == uid) {
return user['name'];
}
}
return 'unknown';
}
}
私が作業した最初の解決策は、2つのStreamBuilder
インスタンスを、各コレクション/クエリごとに1つネストすることです。
class ChatList extends StatelessWidget {
@override
Widget build(BuildContext context) {
var messagesSnapshot = Firestore.instance.collection("chat").orderBy("timestamp", descending: true).snapshots();
var usersSnapshot = Firestore.instance.collection("users").snapshots();
var streamBuilder = StreamBuilder<QuerySnapshot>(
stream: messagesSnapshot,
builder: (BuildContext context, AsyncSnapshot<QuerySnapshot> messagesSnapshot) {
return StreamBuilder(
stream: usersSnapshot,
builder: (context, usersSnapshot) {
if (messagesSnapshot.hasError || usersSnapshot.hasError || !usersSnapshot.hasData)
return new Text('Error: ${messagesSnapshot.error}, ${usersSnapshot.error}');
switch (messagesSnapshot.connectionState) {
case ConnectionState.waiting: return new Text("Loading...");
default:
return new ListView(
children: messagesSnapshot.data.documents.map((DocumentSnapshot doc) {
var user = "";
if (doc['uid'] != null && usersSnapshot.data != null) {
user = doc['uid'];
print('Looking for user $user');
user = usersSnapshot.data.documents.firstWhere((userDoc) => userDoc.documentID == user).data["name"];
}
return new ListTile(
title: new Text(doc['message']),
subtitle: new Text(DateTime.fromMillisecondsSinceEpoch(doc['timestamp']).toString()
+"\n"+user),
);
}).toList()
);
}
});
}
);
return streamBuilder;
}
}
私の質問で述べたように、私はこの解決策は素晴らしいものではないことを知っていますが、少なくともそれは機能します。
私がこれで見るいくつかの問題:
より良い解決策を知っている場合は、回答として投稿してください。