😈

FlutterアプリにRemoteConfigを導入してみた!

2021/09/11に公開1

はじめに

Flutter アプリに FirebaseRemote Config を導入する手順を忘却録として書き残したいと思います。
至らない点がございましたら 、ご指摘いただければ幸いです🙇


Remote Configを導入するメリット

Remote Configを活用することでアプリのアップデートをせずに値の変更ができるので、アプリのトップコンテンツなどで画面に表示している名称や画像を変更したいと思ったときに即座に反映することが可能です。


必要なパッケージを導入

pubspec.yaml内 dependencies の中で、firebase_core , firebase_remote_configを追加します。
また状態管理で hooks_riverpod も追加しておきます。

ご存じの方も多いかと思われますが、Flutterでパッケージを導入する際に拡張機能のPubspec Assistをインストールしておくと便利です。
https://marketplace.visualstudio.com/items?itemName=jeroen-meijer.pubspec-assist

インストール後、以下の手順でパッケージを導入できます

  1. shift + command + pでコマンドパレットを呼び出す
  2. Pubspec Assist:add dependenciesを指定
  3. 必要なパッケージを入力(スペルが曖昧でも補完機能があるので助かっています。)

コードを書く

事前準備

先に firebase 上で RemoteConfig が使える状態にしておきます。
手順は以下のとおりです。

  1. firebase のプロジェクト内で左のタブバーよりエンゲージメント > RemoteConfig に移動
  2. 「構成を作成」というボタンをクリックし、パラメータを作成する

今回は簡単に RemoteConfig の概要のようなパラメータにしようと思います。

次にflutter create後、MVVMの設計をもとにファイル構成を行います。
https://wasabeef.medium.com/flutter-を-mvvm-で実装する-861c5dbcc565

先にフォルダ構成を提示しておきます。

それではコード書いて行きます!

まず Riverpod を使えるようにします。といっても簡単で、main 関数の runApp 内の widget(今回は MyApp)を ProviderScope という Widget で囲うだけです。

lib/main.dart
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'src/ui/my_app/my_app_page.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  runApp(ProviderScope(child: _MaterialApp()));
}

class _MaterialApp extends StatelessWidget {
  
  MaterialApp build(BuildContext context) => const MaterialApp(
        title: 'RemoteConfigPractice',
        home: MyApp(),
      );
}

MyApp は後で view の作成でコード載せます。


Model の作成 (resources & repository)

FlutterFire を参考に、コード上で RemoteConfig を導入するために必要な記述を行います。

lib/src/resources/firebase/remote_config_service.dart
import 'package:firebase_remote_config/firebase_remote_config.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

final remoteConfigServiceProvider = Provider((_) => RemoteConfigService());

class RemoteConfigService {
  RemoteConfig? _remoteConfig;

  Future<void> getRemoteConfig() async {
    _remoteConfig = RemoteConfig.instance;
    await _remoteConfig?.setConfigSettings(RemoteConfigSettings(
      fetchTimeout: const Duration(seconds: 10),
      minimumFetchInterval: const Duration(hours: 1),
    ));//上記2行は公式のとおりに設定しております。
    await _remoteConfig?.fetchAndActivate();
  }

  Future<RemoteConfigValue> getValue(String key) async =>
      _remoteConfig!.getValue(key);
}

各メソッドについての簡単な説明です。

.getRemoteConfig
アプリが起動されたタイミングで呼び出す必要があるメソッド

getValue
各パラーメータに分かれたRemoteConfigのデータを引っ張ってくるメソッド。引数には先程firesbase(RemoteConfig)でパラメータの作成を行った際のパラメータ名を渡します。


次にrepositoryに移ります。
repositoryはViewModelがどこからデータを取得・更新するのかを意識させないためにビジネスロジックとデータ操作を分離することを目的とします。

lib/src/repository/remote_config/remote_content_repository.dart
import 'dart:convert';
import 'dart:io';

import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../../resources/entities/remote_content.dart';
import '../../resources/firebase/remote_config_service.dart';

final remoteContentRepositoryProvider =
    Provider<RemoteContentRepository>((ref) {
  final remoteConfigService = ref.read(remoteConfigServiceProvider);
  return RemoteContentRepository(remoteConfigService: remoteConfigService);
});

class RemoteContentRepository {
  RemoteContentRepository({
    required this.remoteConfigService,
  });

  final RemoteConfigService remoteConfigService;

  //viewModelに渡すためのメソッド
  Future<List<RemoteContent>> fetchRemoteContents() async {
    final data = await remoteConfigService.getValue('RemoteConfig');
    // ignore: unnecessary_null_comparison
    if (data == null) {
      throw const HttpException(
          'Failed to get Remote Config or under loading.');
    }
    final decoded = json.decode((data).asString()).cast<Map<String, dynamic>>()
        as List<Map<String, dynamic>>;
    final remoteContent = await Future.wait(decoded.map(
      (content) async => RemoteContent.fromJson(content),
    ));
    // ignore: unnecessary_null_comparison
    remoteContent.removeWhere((content) => content == null);
    return remoteContent;
  }
}

ここで記載されているRemoteContentとありますが、先程JSON エディタ上で記述した内容をview で呼び出す際タイポしないようにモデルクラスを定義しています。
別ファイルで定義しています。
title や content は先程 firebase(RemoteConfig)上で編集したものです。

lib/src/repository/remote_config/remote_content_repository.dart
class RemoteContent {
  RemoteContent({
    required this.title,
    required this.content,
  });

  factory RemoteContent.fromJson(Map<String, dynamic> data) => RemoteContent(
        title: data['title'],
        content: data['content'],
      );

  final String title;
  final String content;
}

Modelの記述は以上で、次にviewModelに移ります。


viewModel の作成

状態管理が必要な場合、viewModel は1つの view に対し1つ用意するという方針なので、今回は my_app フォルダに view_Model ファイルを用意します。

viewModel の役割は大きく2つあります。

  • View から入力された状態(データ)を適切に変換して Model として持つ
  • Model の状態(データ)を View 渡して画面の更新を促す

今回はまさに2点目の Model(RemoteConfig) のデータ(パラメータ)を view に渡して画面の更新を促す ということをしたいです。
コードは以下のとおりです。

lib/src/ui/my_app/view_model.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../../repository/remote_content_repository.dart';
import '../../resources/entities/remote_content.dart';
import '../../resources/firebase/remote_config_service.dart';

final myAppPageViewModelProvider = ChangeNotifierProvider.autoDispose((ref) {
  final remoteConfigService = ref.read(remoteConfigServiceProvider);
  final remoteContentRepository = ref.read(remoteContentRepositoryProvider);
  return _MyAppPageViewModel(
    remoteConfigService: remoteConfigService,
    remoteContentRepository: remoteContentRepository,
  );
});

class _MyAppPageViewModel extends ChangeNotifier {
  _MyAppPageViewModel({
    required this.remoteConfigService,
    required this.remoteContentRepository,
  }) {
    _initialize();
  }
  final RemoteConfigService remoteConfigService;
  final RemoteContentRepository remoteContentRepository;

  AsyncValue<List<RemoteContent>> _remoteContents = const AsyncValue.loading();
  AsyncValue<List<RemoteContent>> get remoteContents => _remoteContents;

  bool _isLoading = true;
  bool get isLoading => _isLoading;

  Future<void> _initialize() async {
    try {
      await remoteConfigService.getRemoteConfig();
      _isLoading = false;

      final remoteContents =
          await remoteContentRepository.fetchRemoteContents();
      _remoteContents = AsyncValue.data(remoteContents);
      notifyListeners();
    } on Exception catch (e) {
      print(e);
      _remoteContents = AsyncValue.error(e);
    }
  }
}

view の記述

最後に view(my_app_page) です。
AsyncValue(今回でいうと remoteContents)についての説明は以下の記事が参考になります。
https://qiita.com/toda-axiaworks/items/7950cd28c39de18f4ecc

lib/src/ui/my_app/my_app_page.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../../common/error_page.dart';
import '../../common/loading_page.dart';
import '../../common_style.dart';
import 'view_model.dart';

class MyApp extends HookConsumerWidget {
  const MyApp();
  
  Widget build(BuildContext context, WidgetRef ref) {
    final viewModel = ref.watch(myAppPageViewModelProvider);
    final isLoading = viewModel.isLoading;

    if (isLoading) {
      return const LoadingPage();
    }

    return Scaffold(
      body: viewModel.remoteContents.when(
        loading: () => const LoadingPage(),
        error: (_, __) => const ErrorPage(),
        data: (contents) => SafeArea(
          child: ListView.builder(
            itemCount: contents.length,
            itemBuilder: (context, index) {
              final content = contents[index];
              return Container(
                margin: const EdgeInsets.symmetric(vertical: 15),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Container(
                      margin: const EdgeInsets.fromLTRB(15, 0, 15, 10),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          content.title.isEmpty
                              ? const SizedBox()
                              : Text(
                                  content.title,
                                  style: CommonTextStyles.remoteContentTitle,
                                ),
                          const SizedBox(height: 10),
                          content.content.isEmpty
                              ? const SizedBox()
                              : Text(
                                  content.content,
                                  style: CommonTextStyles.remoteContent,
                                ),
                        ],
                      ),
                    ),
                  ],
                ),
              );
            },
          ),
        ),
      ),
    );
  }
}

以上のコードより RemoteConfig からのデータを画面上に反映されました!

以上になります。ここまで読んでいただきありがとうございました!!

Discussion

nekotaronekotaro

Riverpod公式でChangeNotifierProviderは非推奨なので使用しないほうが良さそうです。
この場面ではFeatureProviderを使うのがよさそうです。