Project structure
I have the next Widget, which uses a StreamBuilder to listen a draw a List. (I've omited irrelevant details)
class CloseUsersFromStream extends StatelessWidget {
final Stream<List<User>> _stream;
const CloseUsersFromStream(this._stream);
@override
Widget build(BuildContext context) {
return StreamBuilder<List<User>>(
stream: _stream,
builder: _buildWidgetFromSnapshot,
);
}
Widget _buildWidgetFromSnapshot(_, AsyncSnapshot<List<User>> snapshot) {
if (_snapshotHasLoaded(snapshot)) {
return UsersButtonsList(snapshot.data!);
} else {
return const Center(child: CircularProgressIndicator());
}
}
bool _snapshotHasLoaded(AsyncSnapshot<List<User>> snapshot) {
return snapshot.hasData;
}
}
And its parent:
class CloseUsersScreen extends StatefulWidget {
final ICloseUsersService _closeUsersService;
const CloseUsersScreen(this._closeUsersService);
@override
State<CloseUsersScreen> createState() => _CloseUsersScreenState();
}
class _CloseUsersScreenState extends State<CloseUsersScreen> {
late final Stream<List<User>> _closeUsersStream;
@override
void initState() {
_initializeStream(context);
super.initState();
}
@override
void dispose(){
_closeStream();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
children: [
//...
CloseUsersFromStream(_closeUsersStream),
],
),
);
}
void _initializeStream(BuildContext context) {
_closeUsersStream = widget._closeUsersService.openCloseUsersSubscription(context);
}
void _closeStream(){
widget._closeUsersService.closeCloseUsersSubscription();
}
}
The problem
Sometimes, my FutureBuilder
doesn't rebuild when a new event occurs on the Stream.
With the debugger I've noticed that the stream works properly and the StreamBuilder
receives the data correctly too. But, it doesn't rebuild as expected.
When I say that the StreamBuilder
receives the data correctly, I mean that the debugger stops on the line
return UsersButtonsList(snapshot.data!);
and with the debugger I can see that snapshot.data
is the expected value. Nevertheless, the Widget doesn't redraw.
Unfortunately, I haven't could work out the pattern when the problem appears yet. I think that it's probable that it happens after disposing the StreamBuilder
under specific conditions (even tough it doesn't print any error), but I'm not 100% sure. I'm still trying to identify it and I will add it to the post when I do.
Using my own "StreamBuilder
"
I've tried to build my own StreamBuilder
to avoid using the Flutter one, because I thought the widget could be the problem
class CustomStreamBuilder<T> extends StatefulWidget {
final Stream<T> stream;
final Widget Function(BuildContext context, T? data) builder;
const CustomStreamBuilder({required this.stream, required this.builder});
@override
_CustomStreamBuilderState<T> createState() => _CustomStreamBuilderState<T>();
}
class _CustomStreamBuilderState<T> extends State<CustomStreamBuilder<T>> {
late StreamSubscription<T> _subscription;
T? _data;
@override
void initState() {
super.initState();;
_subscription = widget.stream.listen((data) {
setState(() {
_data = data;
});
});
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.builder(context, _data);
}
}
However, the problem persisted even with this Widget
. The Stream still works properly and, according to the debugger, the Widget should be redrawing. I mean, this lines are executed again when the stream receives an event:
Widget _buildWidgetFromSnapshot(BuildContext context, List<User>? snapshot) {
if(snapshot == null) {
return const Center(child: CircularProgressIndicator());
}else{
return UsersButtonsList(snapshot,_qualityReducer, _messageService); //<-- This line
}
}
And this one:
@override
Widget build(BuildContext context) {
return widget.builder(context, _data); //<-- This line
}
And, as it happened before, the variable _data
has the correct value, given from the event.
Other solutions I've tried
Following what @Sayyid J told me, I've tried using a ValueListenableBuilder
class MyStreamNotifier<T> extends ValueNotifier implements ValueListenable {
MyStreamNotifier({required Stream<T> stream}) : super(null){
notifyListeners();
_subscription = stream.asBroadcastStream().listen((event) {
//when there is event. just rebuild
notifyListeners();
_event = event;
});
_subscription.onError((err) {
//handle the error
});
}
late final StreamSubscription<T> _subscription;
T? _event;
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
@override
get value => _event;
}
class CloseUsersFromStream extends StatelessWidget {
final Stream<List<User>> _stream;
const CloseUsersFromStream(this._stream);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: MyStreamNotifier(stream: _stream),
builder: (context, event, child) {
if (event != null){
return UsersButtonsList(event);
}else{
return const Center(child: CircularProgressIndicator());
}
});
}
}
The debugger stops on the line notifyListeners()
. However, it doesn't stops on builder
function.
Adding a UniqueKey
to the StreamBuilder
@override
Widget build(BuildContext context) {
return StreamBuilder<List<User>>(
key: UniqueKey(),
stream: _stream,
builder: _buildWidgetFromSnapshot,
);
}
As I think the problem could be that the StreamBuilder
is being disposed before it's initialization, this are other things I've tried:
Delaying the disposing a second to let the StreamBuilder
initialize correctly. The problem persisted.
@override
void dispose() {
sleep(const Duration(seconds:1));
_closeStream();
super.dispose();
}
Forcing update the StreamBuilder
when it's build. The problem persisted.
class CloseUsersFromStream extends StatefulWidget {
final Stream<List<User>> _stream;
const CloseUsersFromStream(this._stream);
@override
State<CloseUsersFromStream> createState() => _CloseUsersFromStreamState();
}
class _CloseUsersFromStreamState extends State<CloseUsersFromStream> {
@override
void initState() {
setState(() {});
super.initState();
}
@override
Widget build(BuildContext context) {
return StreamBuilder<List<User>>(
stream: widget._stream,
builder: _buildWidgetFromSnapshot,
);
}
//...
}
Not initializing the the StreamBuilder
until the Stream is initialized. (This doesn't make so much sense because the Stream initialization isn't a future, but I tried anyways)
class _CloseUsersScreenState extends State<CloseUsersScreen> {
Stream<List<User>>? _closeUsersStream;
//...
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
children: [
CloseUsersHeader(),
if(_closeUsersStream != null) // <- This line here
CloseUsersFromStream(_closeUsersStream!),
],
),
);
}
void _initializeStream(BuildContext context) {
setState(() {
_closeUsersStream = widget._closeUsersService.openCloseUsersSubscription(context);
});
}
//...
}
As @B0Andrew has indicated me on the comments, I tried changing the FutureBuilder
's child with a simple Text
, discarding that the problem is on it. But, the problem persisted.
Widget _buildWidgetFromSnapshot(BuildContext context, List<User>? snapshot) {
if(snapshot == null) {
return const Center(child: CircularProgressIndicator());
}else{
// return UsersButtonsList(snapshot;
return Text("Event received");
}
}
Screenshots
This is a little video showing the problem. Note that the CircularProgressIndicator
stays in the screen.
But, as you can see, the debugger does stop on the correct line:
This is another view of the problem, showing the debugger and the app:
Github
This is the Github link, for watching the full project.
Other sources I've read
I've consulted the next sources unsuccessfuly:
Another example
A few days after posting the original issue, I found that this problem happens also in another StreamBuilder
of the project. I've tried to work out the pattern which makes this error appear but I haven't could yet.
This is the StreamBuilder
state widget (again, details are omitted):
class _ChatRendererState extends State<ChatRenderer> {
@override
void dispose() {
widget._chatService.closeChatStream();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future:widget._chatService.getChat(widget._externalUser),
builder:_buildChatFromFutureSnapshot,
);
}
Widget _buildChatFromFutureSnapshot(BuildContext context, AsyncSnapshot snapshot){
if(snapshot.hasData){
return StreamBuilder<Chat>(
initialData: snapshot.data,
stream: widget._chatService.getChatStream(context),
builder: (context, snapshotStream) {
return MessagesListWithLazyLoading(snapshotStream.data!); // <- Debugger stops in this line. But, the Widget doesn't rebuild
},
);
}else{
//...
}
}
And this is the class which gets the Stream from (anyways, as i said before, the Stream works properly)
class ChatStreamService implements IChatStreamService{
StreamController<Chat>? _closeUsersStreamController;
late WebSocketSubscription _chatSubscription;
@override
Stream<Chat> getChatStream(BuildContext context){
_closeUsersStreamController = StreamController<Chat>();
_initializeSubscription(context);
return _closeUsersStreamController!.stream;
}
@override
void closeChatStream(){
_chatSubscription.unsuscribe();
}
void _initializeSubscription(BuildContext context){
_chatSubscription = WebSocketSubscription.activate(
//...
callback: _onChatReceived
);
}
void _onChatReceived(String? frameBody){
final chatJSON = jsonDecode(frameBody!);
Chat chat = Chat.fromJSON(chatJSON);
_closeUsersStreamController!.add(chat);
}
}
Run the project in your own PC
I know it would be really helpful providing a minimal snippet to recreate the problem. However, I haven't been able to reproduce it. All I can do is provide the instructions for running the project. I've "dockerized" the backend for making it simple to run. Fortunately, the project is very small and can be running with just a few commands.
and can be running with this comman on the root of the project:
docker compose up
The only change you have to do to run it is changing the server IP in the config.dart
file (lib/config/config.dart
) with your own IP.
Map<String, Widget Function(BuildContext)> routes = {
//...
};
const String serverURL = '192.168.50.92:8080'; <-- This line
const String initalRoute = 'splash';
const String title = "close";
ThemeData themeData = ThemeData(
//...
)
In my case I run the command
ifconfig | grep "inet 19"
on the terminal and I get my IP.
Reproduce the problem on the full project
Once the project is running, the problem can appear from different ways. One is reproducing the next sequence:
- Register (is very easy. It just has 3 fields and doesn't need any verification)
- Go to the second screen and click on "Cerrar sesión"
- Login with the same credentials
- Go to the second screen again
- Go back to the main screen
And now, the problem should have appear. If doesn't just try reproducing it again. Sometimes it takes one or two tries.
Here is the full sequence in video: