J'ai une application de chat à Flutter en utilisant Firestore, et j'ai deux collections principales:
chats
, qui est incrusté sur les identifiants automatiques et contient les champs message
, timestamp
et uid
.users
, qui est saisi sur uid
, et qui a un champ name
Dans mon application, je montre une liste de messages (de la collection messages
), avec ce widget:
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;
}
}
Mais maintenant, je veux montrer le nom de l'utilisateur (de la collection users
) pour chaque message.
J'appelle normalement cela une jointure côté client, bien que je ne sois pas sûr que Flutter ait un nom spécifique pour cela.
J'ai trouvé un moyen de le faire (que j'ai posté ci-dessous), mais je me demande s'il existe un autre moyen/meilleur/plus idiomatique pour faire ce type d'opération dans Flutter.
Alors: quelle est la façon idiomatique dans Flutter de rechercher le nom d'utilisateur pour chaque message dans la structure ci-dessus?
J'ai obtenu une autre version qui semble légèrement meilleure que ma réponse avec les deux constructeurs imbriqués .
Ici, j'ai isolé le chargement des données dans une méthode personnalisée, en utilisant une classe Message
dédiée pour contenir les informations d'un message Document
et l'utilisateur associé facultatif 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;
}
}
Comparé à solution avec des constructeurs imbriqués ce code est plus lisible, principalement parce que la gestion des données et le générateur d'interface utilisateur sont mieux séparés. Il charge également uniquement les documents utilisateur pour les utilisateurs qui ont publié des messages. Malheureusement, si l'utilisateur a publié plusieurs messages, il chargera le document pour chaque message. Je pourrais ajouter un cache, mais je pense que ce code est déjà un peu long pour ce qu'il accomplit.
Vous pouvez le faire avec RxDart comme ça .. 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([]);
})
}
pour 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([]);
})
}
Si je lis cela correctement, le problème résume: comment transformer un flux de données qui nécessite de faire un appel asynchrone pour modifier les données dans le flux?
Dans le contexte du problème, le flux de données est une liste de messages, et l'appel asynchrone consiste à récupérer les données utilisateur et à mettre à jour les messages avec ces données dans le flux.
Il est possible de le faire directement dans un objet de flux Dart en utilisant la fonction asyncMap()
. Voici du code Dart pur qui montre comment le faire:
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);
}
}
La plupart du code imite les données provenant de Firebase en tant que flux d'une carte de messages et d'une fonction asynchrone pour récupérer les données utilisateur. La fonction importante ici est getMessagesStream()
.
Le code est légèrement compliqué par le fait qu'il s'agit d'une liste de messages provenant du flux. Pour empêcher les appels à récupérer les données utilisateur de se produire de manière synchrone, le code utilise une Future.wait()
pour rassembler un List<Future<Message>>
Et créer un List<Message>
Lorsque tous les Futures sont terminés.
Dans le contexte de Flutter, vous pouvez utiliser le flux provenant de getMessagesStream()
dans un FutureBuilder
pour afficher les objets Message.
Idéalement, vous souhaitez exclure toute logique métier telle que le chargement de données dans un service distinct ou en suivant le modèle BloC, par exemple:
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;
}
}
}
Ensuite, vous pouvez simplement utiliser le Bloc dans votre composant et écouter le chatBloc.messages
stream.
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());
}
});
}
}
Permettez-moi de proposer ma version d'une solution RxDart. J'utilise combineLatest2
avec un ListView.builder
pour créer chaque widget de message. Pendant la construction de chaque Widget de message, je recherche le nom de l'utilisateur avec le uid
correspondant.
Dans cet extrait, j'utilise une recherche linéaire pour le nom de l'utilisateur, mais cela peut être amélioré en créant un uid -> user name
carte
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';
}
}
La première solution que j'ai trouvée consiste à imbriquer deux instances de StreamBuilder
, une pour chaque collection/requête.
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;
}
}
Comme indiqué dans ma question, je sais que cette solution n'est pas excellente, mais au moins cela fonctionne.
Certains problèmes que je vois avec cela:
Si vous connaissez une meilleure solution, veuillez poster comme réponse.