hyoromoのブログ

最近はVRSNS向けに作ったものについて書いています

8年前に作ったアプリをflutterで作り直した


8年前にiOS/Androidネイティブ開発してリリースし、どこかのタイミングで機能しなくなっていた以下のアプリをflutterで1から作り直しました。
8年前の情報は以下のエントリーに書いています。
hyoromo.hatenablog.com

アプリは以下からダウンロード出来ます。
https://itunes.apple.com/jp/app/id793248344
https://play.google.com/store/apps/details?id=jp.hyoromo.nijisearch

機能としてはシンプルで以下の事しか行っていません。そのシンプルな実装をどのように行ったかについて解説します。

  • 端末内の画像を取得
  • 他アプリから画像/テキストデータを受け取る
  • 画像/テキストのPOST
  • Webページ表示
  • AdMob広告の表示
  • Firebaseの利用

アプリ紹介

まずは作り直したアプリ内容を説明します。二次元画像詳細検索サイトをスマホで手軽に検索出来るアプリです。
ブラウザ上の画像や端末内の画像を本アプリへ共有する事で、画像に関する詳細検索が行えます。

なお初めてリリースした当時に二次元画像詳細検索サービスを運営されている方に許可を頂いております。

機能解説

UI以外はほぼ全てpluginで実装されています。
どんな機能に対し、何のpluginを使ったかを書いていきます。

2点注意点があります。

注意点1

pluginのバージョンは開発時のものを書いています。
各plugin項目の最初にpluginページを掲載していますので、そちらで最新バージョン及び使い方を確認ください*1

注意点2

それぞれの説明にあるpluginをyamlへ追記した後、プロジェクト配下にて以下コマンドをコンソールで叩く必要があります*2。これを忘れるとdartファイルでimportを書いても参照先が無くてエラーとなります。

$ flutter pub get
端末内の画像選択

pub.dev

dependencies:
  image_picker: ^0.8.5+3

iOSではカメラロール、Androidではギャラリーを呼び出してユーザーが画像選択出来るImagePickerです。

import 'package:image_picker/image_picker.dart';

Future _getImage() async {
  final XFile? pickedFile = await ImagePicker().pickImage(source: ImageSource.gallery);

  setState(() {
    if (pickedFile != null) {
      File file = File(pickedFile.path);
      // 取得したファイルで何かする
    } else {
      // 画像が未選択
  });
}
他アプリから画像/URLデータを渡されてアプリ起動

pub.dev

dependencies:
  receive_sharing_intent: ^1.4.5

iOSでの名称はShare Extension、Androidでの名称はIntent。決められたデータ形式をアプリで受け取れるようにしておき、他アプリからシェアされた時にデータを受け取りつつアプリ起動する対応を行いました。
今回の場合は画像検索アプリなため、ファイルブラウザアプリから画像ファイルを共有、Webブラウザから画像URLを共有といった事が出来るようにしました。

@override
void initState() {
  super.initState();

  // アプリが起動中に画像を受信した
  _intentDataStreamSubscription = ReceiveSharingIntent.getMediaStream()
      .listen((List<SharedMediaFile> value) {
    setState(() {
      // 画像を受け取った処理
    });
  }, onError: (err) {
    print("getIntentDataStream error: $err");
  });

  // アプリが閉じられている時に画像を受信した
  ReceiveSharingIntent.getInitialMedia().then((List<SharedMediaFile> value) {
    setState(() {
      // 画像を受け取った処理
    });
  });

  // アプリが起動中にテキストを受信した
  _intentDataStreamSubscription =
      ReceiveSharingIntent.getTextStream().listen((String value) {
    setState(() {
      // テキストを受け取った処理
    });
  }, onError: (err) {
    print("getLinkStream error: $err");
  });

  // アプリが閉じられている時にテキストを受信した
  ReceiveSharingIntent.getInitialText().then((String? value) {
    setState(() {
      if (value != null) {
        // テキストを受け取った処理
      }
    });
  });
}

あとはiOS/Androidそれぞれ独自のデータ受信設定を行います。
iOSであればShare Extension、AndroidであればIntentです。receive_sharing_intentを読みつつ、もしエラーで詰まったら各プラットフォームのデータ受信ドキュメントを読むことで対応出来るかと思います。

HTTP通信

pub.dev

dependencies:
  dio: ^4.0.6

API通信するためのplugin。
画像ファイルをサーバーへPOSTする目的で使いました。

import 'package:dio/dio.dart';

void _requestImage(File file) async {
  final dio = Dio();
  final formData = FormData.fromMap({
    'file': await MultipartFile.fromFile(file.path,
        filename: file.path.split('/').last),
  });

  Response resp = await dio.post(
    "https://hoge",
    data: formData,
    options: Options(
        followRedirects: false,
        validateStatus: (status) {
          return status != null && status < 600;
        }),
  );
    
  // 以降、resp.statusCode のステータスコードを元に処理する
}
Webページ表示

pub.dev

dependencies:
  webview_flutter: ^3.0.4

WebViewを表示するpluginです。
JavaScriptを有効にしたり、ページ読込時や完了時のイベントコールされるのでシンプルにWebページ表示したい時に良いです。

Webページ表示に関してはplugin説明に書いてある以上の事は行っていないためコードは割愛します。

アプリアイコン


pub.dev

AndroidはminSdkVersionを気にする必要があります。もしplugin上に記載されているdefaultよりアプリ側が小さい場合は指定してしておきます。

dev_dependencies:
  flutter_launcher_icons: ^0.10.0

flutter_icons:
  android: "launcher_icon"
  ios: true
  image_path: "lib/assets/icon.png"
  min_sdk_android: 20 # android min sdk min:16, default 21

image_pathに配置したストアへアップロード用の大きいサイズ画像を配置します。
iOS/Androidそれぞれに必要なアプリアイコンファイルを作成して置き換えてくれます。
yamlファイルでの設定が終わったら、コンソール上でプロジェクト直下へ移動して以下を実行します。

flutter pub get
flutter pub run flutter_launcher_icons:main
ローカライズ対応

plugins.jetbrains.com

dependencies:
  flutter_localizations:
    sdk: flutter

ローカライズ対応だけ特別で、IDE側のpluginで対応しました。ドキュメントを読んでもよく分からない点が多かったため、メモ書きレベルで手順を残しておきます。

  1. yamlファイルへ flutter_localizations を追加
  2. Android Studio*3へ上リンク先にあるFlutter Intlを追加
  3. Android Studio上メニューから、"Tools > Flutter Intl > Initialize for the Project" でプロジェクトへ必要ファイル追加
  4. Android Studio上メニューから、"Tools > Flutter Intl > Add Locale" で対応言語追加
  5. lib/I10n/intl_xx.arb ファイルへテキスト追加
  6. アプリ起動時に呼ばれるソースコード(main.dartとか)に、以下のように localizationsDelegates からを追加。言語は対応ファイル分追加するのですが、簡体字/繁体字の設定がiOS/Androidの両方で上手く行かなかったため試行錯誤しました。以下のzh周りは重複設定があるかもしれません。
import 'generated/l10n.dart';
import 'package:flutter_localizations/flutter_localizations.dart';

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {

    return MaterialApp(
      title: 'アプリタイトル',
      localizationsDelegates: [
        S.delegate,
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate
      ],
      supportedLocales: [
        const Locale('en', ''), //英語
        const Locale('ja', ''), //日本語
        const Locale('ko', ''), //韓国語
        Locale.fromSubtags(languageCode: 'zh'), // 簡体字
        Locale.fromSubtags(
            languageCode: 'zh',
            scriptCode: 'Hans'), // 簡体字 'zh_Hans'
        Locale.fromSubtags(
            languageCode: 'zh',
            scriptCode: 'Hant'), // 繁体字 'zh_Hant'
        Locale.fromSubtags(
            languageCode: 'zh',
            scriptCode: 'Hans',
            countryCode: 'CN'), // 簡体字(中国) 'zh_Hans_CN'
        Locale.fromSubtags(
            languageCode: 'zh',
            scriptCode: 'Hant',
            countryCode: 'TW'), // 繁体字(台湾) 'zh_Hant_TW'
      ],

テキスト表示は以下のどちらかで行なえます。

S.of(context).main_title
S.current.main_title
Firebase Analytics

pub.dev

  firebase_core: ^1.20.1
  firebase_analytics: ^9.3.1
  1. yamlファイルへ firebase_core と firebase_analytics を追加
  2. コンソールアプリ*4を起動して以下のようにFirebaseへログイン
    $ firebase login
  3. コンソールアプリ上でflutterプロジェクトのディレクトリまで移動し、以下を実行
    $ dart pub global activate flutterfire_cli
  4. コンソールアプリ上で以下を実行
    $ flutterfire configure
  5. アプリ起動時に呼ばれるソースコード(main.dartとか)へ後述するコードを追加
  6. Firebaseサイト上でアプリのプロジェクトを作成し、iOS/Androidアプリを追加

なお、コンソール上の操作は上記よりもFirebase公式ドキュメントをちゃんと読んで実行する方が分かりやすいし確実です。
firebase.google.com

コードは起動時に初期化を追加するだけで計測出来るようになります。

import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';

Future<void> main() async {
  // Firebase初期化
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
AdMob広告


pub.dev
developers.google.com

dependencies:
  google_mobile_ads: ^2.0.1

現時点ではAdMob提供の以下広告種類がflutter対応されているようです。

iOS/Android毎にAdMobのIDをそれぞれ設定、iOSであれば後述するATT対応が必要です。
コード上ではアプリ起動時に初期化を行い、bodyに広告をレイアウトするだけです。
広告レイアウト時はサイズを指定可能で、以下の例はレクタングルバナーが表示されます。

import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'ad_banner.dart';

Future<void> main() async {
  MobileAds.instance.initialize(); // AdMob初期化

  runApp(const MyApp());
}


@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      // 省略
    ),
    body:
      Center(
        child: Column(mainAxisSize: MainAxisSize.min, children: [
          // 省略
          AdBanner(size: AdSize.mediumRectangle),
        ]),
      ),
  );
}

アプリ起動広告は現時点*5ドキュメントの対応一覧に記載はありませんが以下のようにすれば呼び出せます。

late AppLifecycleReactor _appLifecycleReactor;
  
void _showAppOpenAd() {
  AppOpenAdManager appOpenAdManager = AppOpenAdManager()..loadAd();
  _appLifecycleReactor = AppLifecycleReactor(
      appOpenAdManager: appOpenAdManager);
}
広告用のiOS AppTrackingTransparency(ATT)対応


iOS14.5以上で広告掲載する場合、広告トラッキング許諾を行う必要があるのでAppTrackingTransparency対応がほぼ必須*6となっています。この対応はXcode上だけで対応すれば良いためネイティブ対応で済みますが、pluginがあったのでせっかくだから使ってみました。

dependencies:
  app_tracking_transparency: ^2.0.2+4

コードは簡単で以下を呼び出すだけ。

AppTrackingTransparency.requestTrackingAuthorization();

でもシステムのATTダイアログを出す前に独自のお願いダイアログを出した方がユーザーに許可して貰いやすいようなので、ATTの許諾ステータスを取得して独自ダイアログを出すようにした方が良い*7

void showATTDialog(BuildContext context) async {
  final TrackingStatus status = await AppTrackingTransparency.trackingAuthorizationStatus;
  if (await AppTrackingTransparency.trackingAuthorizationStatus == TrackingStatus.notDetermined) {
    await showDialog(
      context: context,
      builder: (_) {
        return AlertDialog(
          title: Text("ATTダイアログ表示前のお願いタイトル"),
          content: Text("ATTダイアログ表示前のお願い文章"),
          actions: [
            ElevatedButton(
              child: Text("OK"),
              onPressed: () {
                AppTrackingTransparency.requestTrackingAuthorization();
                Navigator.pop(context);
              },
            ),
          ],
        );
      }
    );
  }
}

Info.plistにNSUserTrackingUsageDescriptionを追加し、システムダイアログ上に表示するトラッキング理由説明文を記載。SKAdNetworkを追加し、表示する広告のSKAdNetworkIdentifierを追加します。


ATT自体の詳細に関しては以下に書いているのでそちらを参照ください。
hyoromo.hatenablog.com

ライセンス表示


dev_dependencies:
  package_info: ^2.0.2

上図のページは以下のコードを呼び出すだけで表示されます。

import 'package:package_info/package_info.dart';
~~~~~~

final info = PackageInfo.fromPlatform();
showLicensePage(
  context: context,
  applicationName: info.appName,
  applicationVersion: info.version
);

plugin導入時に気を付ける事

pluingはググって目的に合致したplugin名を探し、 Dart packages で最新バージョンと使い方の情報を得るのが良かったです。
というのも更新頻度の高いpluginは情報鮮度がすぐ落ちるため、pub.dev以外に書かれてある手順を参考にすると予期せずハマる場合があります。pub.dev記載の使い方を最初に読んで、それで分からなければググってみるくらいが良さそうでした。

また、pub.dev上のドキュメントが更新されていないパターンもあります。
AdMobやFirebaseなどに関してはGoogleが専用ドキュメントを別途用意しており、そちらに最新情報が掲載されているパターンがありました。

まとめ

軽くflutterの入門ドキュメンに目を通し、そこから1週間くらいでiOS/Androidの審査提出まで行えて楽でした*8
flutterはpluginが豊富にあるので簡単なツールアプリだとパズルのようにpluginを組み合わせるだけで出来ちゃうのかもしれませんね!

*1:バージョンが上がれば使い方が変わっていたりするため

*2:繰り返しになるため説明を省いています

*3:VSCodeでも可

*4:Macだとコマンドプロンプト

*5:2022/08/29

*6:対応しなくても広告は出ますがeCPMが低くなると言われています

*7:適当にAlertDialog表示しているが、イラストありの丁寧なダイアログを表示するほうが望ましい

*8:ただそこからiOS/Androidとも何度もrejectされ続けたため審査通過に2週間近く掛かりましたが