あなたのAPIデータのローカルコピーを保つために、Flutter


ヶ月前に私はarticle そこで、リポジトリパターンを使用してAPIからダウンロードしたデータのローカルコピーを保存する方法を説明しました.
この記事では、同じ実装を行いますが、いくつかの改善を適用するつもりです.私の目標を達成するために必要なコード量を減らすために、より多くのパッケージとプラグインを利用します.

📽 Video version available on YouTube and Odysee


基本的に私が達成したいのは、リモートAPIから取得したデータのデータベースコピーを保つことができることです.また、これは、アプリケーションがインターネットに接続せずに使用することができます.
この記事で説明すること
  • フラッタにおけるリポジトリパターンの説明と応用
  • APIリクエストを使用する方法dio パッケージ
  • SQLiteデータベースとの対話方法sqflite
  • より速くモデルを書く方法json_serializable
  • 私がこの記事の範囲から出るつもりです.
  • 私が使用するUIは最小限になるでしょう、この記事はユーザーインターフェースを作成することに集中しません
  • 私はどんなデータ同期システムも実装するつもりはありません、理由はそれをするいくつかの方法があるということです、そして、それについて話すために全く新しい記事を作成することはより適切です
  • リポジトリパターン


    この開発パターンは通常、Clean Architecture . その中で2つの基本要素があります.
  • データソース:このコンポーネントは、データに対して生の操作を行います.例は、APIに対する呼び出しを行うための専用のクラス、またはデータベースに対してクエリを実行するデータアクセスオブジェクト(DAO)です.
  • リポジトリ:このコンポーネントは、1つ以上のデータソースを明確にし、データがどこからまたはその方向に流れるかを決定します.
  • この特定の例では、我々はこれを消費するつもりですRecipe API 我々はレシピのリストを取得します.このため、これらのAPIリクエストを作成するためのクラスを作成します.このデータをデータベースに永続化するには、2番目のデータソースとなるDAO(Data Access Object)を作成します.
    これらの2つのデータ源はタイプリポジトリのオブジェクトによって連結されるでしょう.
    これは典型的なリポジトリパターンの簡略形式である.私は古典的な抽象化のすべてを実装しません、理由は私の経験においてそれをするこの方法が小さな媒体サイズアプリケーションに絶好であるということです.多くのデータを管理し、いくつかの開発者が働くつもりである非常に複雑なアプリケーションを開発しなければならないならば、私はあなたにこの記事の後で古典的なきれいな建築を研究して、より深くそれについて学ぶように助言します.

    プロジェクト設定


    フラッタープロジェクトを作成し、次の依存関係を追加しますpubspec.yaml :
    dependencies:
      dio: 4.0.6
      flutter:
        sdk: flutter
      json_annotation: 4.4.0
      logger: 1.1.0
      path: 1.8.0
      path_provider: 2.0.9
      provider: 6.0.2
      sqflite: 2.0.2
    
    dev_dependencies:
      build_runner: 2.1.8
      flutter_test:
        sdk: flutter
      json_serializable: 6.1.5
      lint: 1.8.2
    
    今、我々はビジネス層を定義するつもりですdomain , そして、それでrecipe.dart 我々が我々のレシピを持つファイル
    // lib/domain/recipe.dart
    
    class Recipe {
      final int id;
      final String name;
      final String thumbnailUrl;
      final String description;
    
      const Recipe({
        required this.id,
        required this.name,
        required this.thumbnailUrl,
        required this.description,
      });
    }
    
    

    ネットワーク層の作成


    次のステップはネットワークコールを実行できるように必要なクラスを作成することです.まず第一に私たちは創造するつもりですdata/network/entity/ ファイルrecipe_entity.dart ここでは、ReceipeEntityネットワークモデルを使用します.このモデルはリモートAPIから受け取るデータのコピーです.
    // lib/data/network/entity/recipe_entity.dart
    
    import 'package:json_annotation/json_annotation.dart';
    
    part 'recipe_entity.g.dart';
    
    @JsonSerializable()
    class RecipeListResponse {
      final int count;
      final List<RecipeEntity> results;
    
      RecipeListResponse({required this.count, required this.results});
    
      factory RecipeListResponse.fromJson(Map<String, dynamic> json) =>
          _$RecipeListResponseFromJson(json);
    }
    
    @JsonSerializable()
    class RecipeEntity {
      final int id;
      final String name;
      @JsonKey(name: 'thumbnail_url')
      final String thumbnailUrl;
      final String description;
    
      RecipeEntity({
        required this.id,
        required this.name,
        required this.thumbnailUrl,
        required this.description,
      });
    
      factory RecipeEntity.fromJson(Map<String, dynamic> json) =>
          _$RecipeEntityFromJson(json);
    }
    
    これまでのチュートリアルに従った場合は、IDEでエラーが表示されますpart 'recipe_entity.g.dart'; . それはfromJson() メソッドは生成されません.以下のコマンドを実行して自動生成します.flutter pub run build_runner buildさて、コールを実行するクラスを作成できます.私たちは使用するつもりですdio 書くコードの量を最小にしながらこれを実現するには:
    // lib/data/network/client/api_client.dart
    
    import 'package:dio/dio.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/data/network/entity/recipe_entity.dart';
    
    class KoException implements Exception {
      final int statusCode;
      final String? message;
    
      const KoException({required this.statusCode, this.message});
    
      @override
      String toString() {
        return 'KoException: statusCode: $statusCode, message: ${message ?? 'No message specified'}';
      }
    }
    
    class ApiClient {
      final String baseUrl;
      final String apiKey;
    
      ApiClient({
        required this.baseUrl,
        required this.apiKey,
      });
    
      Future<RecipeListResponse> getRecipes() async {
        try {
          final response = await Dio().get(
            'https://$baseUrl/recipes/list',
            queryParameters: {
              'from': 0,
              'size': 20,
            },
            options: Options(
              headers: {
                'X-RapidAPI-Host': baseUrl,
                'X-RapidAPI-Key': apiKey,
              },
            ),
          );
    
          if (response.data != null) {
            final data = response.data;
    
            return RecipeListResponse.fromJson(data as Map<String, dynamic>);
          } else {
            throw Exception('Could not parse response.');
          }
        } on DioError catch (e) {
          if (e.response != null && e.response!.statusCode != null) {
            throw KoException(
              statusCode: e.response!.statusCode!,
              message: e.response!.data.toString(),
            );
          } else {
            throw Exception(e.message);
          }
        }
      }
    }
    

    データベース


    さて、データベースパーツを実装します.まず最初に、データベースモデルを作成しますlib/data/database/entity :
    // lib/data/database/entity/recipe_db_entity.dart
    
    class RecipeDbEntity {
      static const fieldId = 'id';
      static const fieldName = 'name';
      static const fieldThumbnailUrl = 'thumbnail_url';
      static const fieldDescription = 'description';
    
      final int id;
      final String name;
      final String thumbnailUrl;
      final String description;
    
      const RecipeDbEntity({
        required this.id,
        required this.name,
        required this.thumbnailUrl,
        required this.description,
      });
    
      RecipeDbEntity.fromMap(Map<String, dynamic> map)
          : id = map[fieldId] as int,
            name = map[fieldName] as String,
            thumbnailUrl = map[fieldThumbnailUrl] as String,
            description = map[fieldDescription] as String;
    
      Map<String, dynamic> toMap() => {
            fieldId: id,
            fieldName: name,
            fieldThumbnailUrl: thumbnailUrl,
            fieldDescription: description,
          };
    }
    
    この例のモデルに注意してください:あなたが気づいたかもしれないように、私は同じデータを含む3つの異なるモデルを作成しています.一つのクラスでそれをまとめる人がいて、必ずしも悪いことではない.しかし、ほとんどの場合、それぞれの層のそれぞれのモデルが異なったニーズに応えるので、私はそれを分離するのを好みます.いくつかのモデルを作成すると、たとえ最初に同じであっても、新しい要件が発生するにつれて、異なる進化と変換を持つことができるようになります.このようにして、層の結合を低減している.
    次はデータベースで動作するDAOを作成することです.まず第一に、我々は2009年にベースとして役立つ抽象クラスを作成しますdata/database/dao/base_dao.dart :
    // lib/data/database/dao/base_dao.dart
    
    import 'package:flutter/widgets.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/data/database/entity/recipe_db_entity.dart';
    import 'package:path/path.dart';
    import 'package:sqflite/sqflite.dart';
    
    abstract class BaseDao {
      static const databaseName = 'data-layer-sample-v2.db';
    
      static const recipeTableName = 'recipe';
    
      @protected
      Future<Database> getDatabase() async {
        return openDatabase(
          join(await getDatabasesPath(), databaseName),
          onCreate: (db, version) async {
            final batch = db.batch();
            _createRecipeTable(batch);
            await batch.commit();
          },
          version: 1,
        );
      }
    
      void _createRecipeTable(Batch batch) {
        batch.execute(
          '''
          CREATE TABLE $recipeTableName(
          ${RecipeDbEntity.fieldId} INTEGER PRIMARY KEY NOT NULL,
          ${RecipeDbEntity.fieldName} TEXT NOT NULL,
          ${RecipeDbEntity.fieldThumbnailUrl} TEXT NOT NULL,
          ${RecipeDbEntity.fieldDescription} TEXT NOT NULL
          );
          ''',
        );
      }
    }
    
    さて、レシピdaoを作ります.
    // lib/data/database/dao/recipe_dao.dart
    
    import 'package:flutter_data_layer_repository_pattern_v2/data/database/dao/base_dao.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/data/database/entity/recipe_db_entity.dart';
    
    class RecipeDao extends BaseDao {
      Future<List<RecipeDbEntity>> selectAll() async {
        final db = await getDatabase();
        final List<Map<String, dynamic>> maps =
            await db.query(BaseDao.recipeTableName);
        return List.generate(maps.length, (i) => RecipeDbEntity.fromMap(maps[i]));
      }
    
      Future<void> insertAll(List<RecipeDbEntity> assets) async {
        final db = await getDatabase();
        final batch = db.batch();
    
        for (final asset in assets) {
          batch.insert(BaseDao.recipeTableName, asset.toMap());
        }
    
        await batch.commit();
      }
    }
    

    データマッピング


    それぞれのレイヤーの排他的なモデルを持っているので、それらの間で変換できるメカニズムを作成しなければなりませんdata :
    // lib/data/mapper.dart
    
    import 'package:flutter_data_layer_repository_pattern_v2/data/database/entity/recipe_db_entity.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/data/network/entity/recipe_entity.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/domain/recipe.dart';
    
    class MapperException<From, To> implements Exception {
      final String message;
    
      const MapperException(this.message);
    
      @override
      String toString() {
        return 'Error when mapping class $From to $To: $message}';
      }
    }
    
    class Mapper {
      Recipe toRecipe(RecipeEntity entity) {
        try {
          return Recipe(
            id: entity.id,
            name: entity.name,
            thumbnailUrl: entity.thumbnailUrl,
            description: entity.description,
          );
        } catch (e) {
          throw MapperException<RecipeEntity, Recipe>(e.toString());
        }
      }
    
      List<Recipe> toRecipes(List<RecipeEntity> entities) {
        final List<Recipe> recipes = [];
    
        for (final entity in entities) {
          recipes.add(toRecipe(entity));
        }
    
        return recipes;
      }
    
      Recipe toRecipeFromDb(RecipeDbEntity entity) {
        try {
          return Recipe(
            id: entity.id,
            name: entity.name,
            thumbnailUrl: entity.thumbnailUrl,
            description: entity.description,
          );
        } catch (e) {
          throw MapperException<RecipeDbEntity, Recipe>(e.toString());
        }
      }
    
      List<Recipe> toRecipesFromDb(List<RecipeDbEntity> entities) {
        final List<Recipe> recipes = [];
    
        for (final entity in entities) {
          recipes.add(toRecipeFromDb(entity));
        }
    
        return recipes;
      }
    
      RecipeDbEntity toRecipeDbEntity(Recipe recipe) {
        try {
          return RecipeDbEntity(
            id: recipe.id,
            name: recipe.name,
            thumbnailUrl: recipe.thumbnailUrl,
            description: recipe.description,
          );
        } catch (e) {
          throw MapperException<Recipe, RecipeDbEntity>(e.toString());
        }
      }
    
      List<RecipeDbEntity> toRecipesDbEntity(List<Recipe> entities) {
        final List<RecipeDbEntity> list = [];
    
        for (final entity in entities) {
          list.add(toRecipeDbEntity(entity));
        }
    
        return list;
      }
    }
    

    リポジトリを通してデータを調合すること


    今、我々はリポジトリを中心に我々のリポジトリを作成するつもりですRecipe どちらがdata/repository . この関数は、データベースからデータを提供するか、リモートAPIからデータを出力します.
    // lib/data/repository/recipe_repository.dart
    
    import 'package:flutter_data_layer_repository_pattern_v2/data/database/dao/recipe_dao.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/data/mapper.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/data/network/client/api_client.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/domain/recipe.dart';
    
    class RecipeRepository {
      final ApiClient apiClient;
      final Mapper mapper;
      final RecipeDao recipeDao;
    
      RecipeRepository({
        required this.apiClient,
        required this.mapper,
        required this.recipeDao,
      });
    
      Future<List<Recipe>> getRecipes() async {
        // First, try to fetch the recipes from database
        final dbEntities = await recipeDao.selectAll();
    
        if (dbEntities.isNotEmpty) {
          return mapper.toRecipesFromDb(dbEntities);
        }
    
        // If the database is empty, fetch from the API, saved it to database,
        // and return it to the caller
        final response = await apiClient.getRecipes();
        final recipes = mapper.toRecipes(response.results);
    
        await recipeDao.insertAll(mapper.toRecipesDbEntity(recipes));
    
        return recipes;
      }
    }
    

    プレゼンテーション層


    上記のすべての結果を見るために、我々はAを作成するつもりですpresentation 我々が我々の表示層を置くフォルダ.まず最初に、MaterialAppをサポートするウィジェットを作成します.
    // lib/presentation/app.dart
    
    import 'package:flutter/foundation.dart';
    import 'package:flutter/material.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/data.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/data/database/dao/recipe_dao.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/data/mapper.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/data/network/client/api_client.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/data/repository/recipe_repository.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/presentation/main_screen.dart';
    import 'package:logger/logger.dart';
    import 'package:provider/provider.dart';
    
    class App extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MultiProvider(
          providers: [
            Provider<Logger>(
              create: (_) => Logger(
                printer: PrettyPrinter(),
                level: kDebugMode ? Level.verbose : Level.nothing,
              ),
            ),
            Provider<RecipeRepository>(
              create: (_) => RecipeRepository(
                apiClient:
                    ApiClient(baseUrl: 'tasty.p.rapidapi.com', apiKey: apiKey),
                mapper: Mapper(),
                recipeDao: RecipeDao(),
              ),
            ),
          ],
          child: MaterialApp(
            home: MainScreen(),
          ),
        );
      }
    }
    
    私のケースでは、私はこれらの例Githubに公開しているので、私も作成してlib/data.dart 私のAPIキーを持つファイル.私は私のパスワードを秘密に保つためにこの方法を行う.コードを配布しない場合は、そのクラスで直接キーを書くことができます.
    変更するlib/main.dart 次のようになります.
    // lib/main.dart
    
    import 'package:flutter/foundation.dart';
    import 'package:flutter/material.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/presentation/app.dart';
    import 'package:sqflite/sqflite.dart';
    
    void main() {
      WidgetsFlutterBinding.ensureInitialized();
    
      // ignore: deprecated_member_use, avoid_redundant_argument_values
      Sqflite.devSetDebugModeOn(kDebugMode);
    
      runApp(App());
    }
    
    終わるために、私はAをつくるつもりですMainScreen ウィジェットのレシピのリストを取得し、別のと呼ばれるRecipeDetail これには、レシピの具体的な詳細が含まれます.
    // lib/presentation/main_screen.dart
    
    import 'package:flutter/material.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/data/repository/recipe_repository.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/domain/recipe.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/presentation/recipe_details.dart';
    import 'package:logger/logger.dart';
    import 'package:provider/provider.dart';
    
    class MainScreen extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Data layer sample')),
          body: FutureBuilder<List<Recipe>>(
            future: Provider.of<RecipeRepository>(context).getRecipes(),
            builder: (BuildContext context, AsyncSnapshot<List<Recipe>> snapshot) {
              if (snapshot.hasData) {
                return ListView.separated(
                  itemBuilder: (context, index) {
                    final recipe = snapshot.data![index];
    
                    return ListTile(
                      leading: SizedBox(
                        width: 48.0,
                        height: 48.0,
                        child: ClipOval(
                          child: Image.network(recipe.thumbnailUrl),
                        ),
                      ),
                      title: Text(recipe.name),
                      onTap: () => Navigator.push(
                        context,
                        MaterialPageRoute(
                          builder: (context) => RecipeDetails(recipe: recipe),
                        ),
                      ),
                    );
                  },
                  separatorBuilder: (context, index) => const Divider(),
                  itemCount: snapshot.data!.length,
                );
              } else if (snapshot.hasError) {
                Provider.of<Logger>(context)
                    .e('Error while fetching data: ${snapshot.error.toString()}');
                return const Center(
                  child: Text('An error occurred while fetching data.'),
                );
              } else {
                return const Center(
                  child: CircularProgressIndicator(),
                );
              }
            },
          ),
        );
      }
    }
    
    import 'package:flutter/material.dart';
    import 'package:flutter_data_layer_repository_pattern_v2/domain/recipe.dart';
    
    class RecipeDetails extends StatelessWidget {
      final Recipe recipe;
    
      const RecipeDetails({required this.recipe});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text(recipe.name),
          ),
          body: SafeArea(
            child: Container(
              padding: const EdgeInsets.only(
                left: 16.0,
                top: 24.0,
                right: 16.0,
                bottom: 24.0,
              ),
              child: Text(recipe.description),
            ),
          ),
        );
      }
    }
    

    結論


    これは、我々の小さな例が終わるところです.この例では2つのデータソースを実装していますが、他のリモートAPI、デバイスキャッシュなどのように実装する必要があります.
    このコードにできるいくつかの改善は、より良いエラー処理であり、より洗練されたプレゼンテーション層を追加したり、データベース内のデータが常にAPI内のデータと同期していることを保証するための同期機構を追加することです.
    具体例のソースコードを見ることができますhere .
    この遠く、ハッピーコーディングを読んでくれてありがとう!