Home > Software engineering >  Racing problem of async function in Flutter
Racing problem of async function in Flutter

Time:12-24

I have a Flutter page that makes use of 2 data sources: one from API (Internet) and one from Shared Preferences. The API source has no problem, as I used FutureBuilder in the build() method. For the Shared Preferences, I have no idea how to apply another Future Builder (or should I add one more?). Here are the codes (I tried to simplify them):

Future<List<City>> fetchCities(http.Client client) async {
  final response = await client
      .get(Uri.parse('https://example.com/api/'));
  return compute(parseCities, response.body);
}

List<City> parseCities(String responseBody) {
  final parsed = jsonDecode(responseBody).cast<Map<String, dynamic>>();
  return parsed.map<City>((json) => City.fromJson(json)).toList();
}

class CityScreen extends StatelessWidget {
  static const routeName = '/city';

  const CityScreen({super.key, required this.title});
  final String title;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text(title),
        ),
        body: FutureBuilder<List<City>>(
          future: fetchCities(http.Client()),
          builder: (context, snapshot) {
            if (snapshot.hasError) {
              return Center(
                child: Text(snapshot.error.toString()),
              );
            } else if (snapshot.hasData) {
              return CityList(cities: snapshot.data!);
            } else {
              return const Center(
                child: CircularProgressIndicator(),
              );
            }
          },
        )
    );
  }
}

class CityList extends StatefulWidget {
  const CityList({super.key, required this.cities});

  final List<City> cities;

  @override
  State<CityList> createState() => _CityListState();
}

class _CityListState extends State<CityList> {
  List<String> completedMissionIDs = [];
  @override
  void initState() {
    super.initState();
    Player.loadMissionStatus().then((List<String> result) {
      setState(() {
        completedMissionIDs = result;
        if (kDebugMode) {
          print(completedMissionIDs);
        }
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      padding: const EdgeInsets.all(16.0),
      itemCount: widget.cities.length * 2,
      itemBuilder: (context, i) {
        if (i.isOdd) return const Divider();
        final index = i ~/ 2;

        double completedPercent = _calculateCompletionPercent(widget.cities[index].missionIDs, completedMissionIDs);

        return ListTile(
          leading: const Icon(Glyphicon.geo, color: Colors.blue),
          title: Text(widget.cities[index].cityName),
          trailing: Text('$completedPercent%'),
          onTap: () {
            Navigator.of(context).push(
                MaterialPageRoute(
                  builder: (context) => MissionScreen(title: '${widget.cities[index].cityName} Missions', cityId: widget.cities[index].id),
                )
            );
          },
        );
      },
    );
  }

  double _calculateCompletionPercent<T>(List<T> cityMissionList, List<T> completedList) {

    if(cityMissionList.isEmpty) {
      return 0;
    }
    int completedCount = 0;
    for (var element in completedList) {
      if(cityMissionList.contains(element)) {
        completedCount  ;
      }
    }
    if (kDebugMode) {
      print('Completed: $completedCount, Total: ${cityMissionList.length}');
    }
    return completedCount / cityMissionList.length;
  }
}

The problem is, the build function in the _CityListState loads faster than the Player.loadMissionStatus() method in the initState, which loads a List<int> from shared preferences.

The shared preferences are loaded in the midway of the ListTiles are generated, making the result of completedPercent inaccurate. How can I ask the ListTile to be built after the completedPercent has been built?

Thanks.

CodePudding user response:

First of all I would separate the data layer from the presentation. Bloc would be one example.

To combine 2 Futures you could do something like

final multiApiResult = await Future.wait([
  sharedPrefs.get(),
  Player.loadMissionStatus()
])

CodePudding user response:

I would start by making CityList a StatelessWidget that accepts completedMissionIDs as a constructor parameter.

Your CityScreen widget can call both APIs and combine the results into a single Future. Pass the combined Future to your FutureBuilder. That way you can render the CityList once all of the data has arrived from both APIs.

I put together a demo below:

import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialApp(
    home: CityScreen(title: 'City Screen'),
  ));
}

class CombinedResult {
  final List<City> cities;
  final List<int> status;
  const CombinedResult({
    required this.cities,
    required this.status,
  });
}

class City {
  final String cityName;
  final List<int> missionIDs;
  const City(this.cityName, this.missionIDs);
}

class Player {
  static Future<List<int>> loadMissionStatus() async {
    await Future.delayed(const Duration(seconds: 1));
    return [0, 3];
  }
}

Future<List<City>> fetchCities() async {
  await Future.delayed(const Duration(seconds: 2));
  return const [
    City('Chicago', [1, 2, 3, 4]),
    City('Helsinki', [1, 2, 3, 4]),
    City('Kathmandu', [0, 4]),
    City('Seoul', [1, 2, 3]),
  ];
}

class CityScreen extends StatefulWidget {
  const CityScreen({super.key, required this.title});
  final String title;

  @override
  State<CityScreen> createState() => _CityScreenState();
}

class _CityScreenState extends State<CityScreen> {
  late Future<CombinedResult> _future;

  @override
  void initState() {
    super.initState();
    _future = _fetchData();
  }

  Future<CombinedResult> _fetchData() async {
    final cities = await fetchCities();
    final status = await Player.loadMissionStatus();
    return CombinedResult(cities: cities, status: status);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: FutureBuilder<CombinedResult>(
        future: _future,
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Center(
              child: Text(snapshot.error.toString()),
            );
          } else if (snapshot.hasData) {
            return CityList(
              cities: snapshot.data!.cities,
              completedMissionIDs: snapshot.data!.status,
            );
          } else {
            return const Center(
              child: CircularProgressIndicator(),
            );
          }
        },
      ),
    );
  }
}

class CityList extends StatelessWidget {
  const CityList({
    super.key,
    required this.cities,
    required this.completedMissionIDs,
  });

  final List<City> cities;
  final List<int> completedMissionIDs;

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      padding: const EdgeInsets.all(16.0),
      itemCount: cities.length,
      separatorBuilder: (context, i) => const Divider(),
      itemBuilder: (context, i) => ListTile(
        leading: const Icon(Icons.location_city, color: Colors.blue),
        title: Text(cities[i].cityName),
        trailing: Text(
            '${_calculateCompletionPercent(cities[i].missionIDs, completedMissionIDs)}%'),
      ),
    );
  }

  double _calculateCompletionPercent<T>(
          List<T> cityMissionList, List<T> completedList) =>
      completedList.where(cityMissionList.contains).length /
      cityMissionList.length;
}
  • Related