20200803のAndroidに関する記事は7件です。

道案内ボランティア向けのアプリ開発プロジェクト

1 はじめに

みなさんはじめまして。塚原卜伝と申します。

仕事ではC,C++,pythonを扱っています。

仕事の合間に外国人道案内ボランティア団体であるask me!での活動に参加しています。

ask me! HP : https://askme.ne.jp/
Twitter  : https://twitter.com/askmeJAPAN
Facebook  : https://www.facebook.com/weareaskme
Instagram  :https://www.instagram.com/we.are.askme/?hl=ja

2020年の3月にask me!での活動の効率化のためにAndroid&iOSアプリ(ask me! app)をリリースしました。

今回初めての個人開発で大変な所も多かったですが、楽しい所もありました。

今回は自分がどのように「ask me! app」を作っていったのかをまとめたいと思います。

2 ask me!の活動紹介

ask me! appはask me!での活動がベースになって作られたものなので、まずは活動内容の説明をしたいと思います。

活動中は困っていそうな外国人に積極的に話しかけていき、彼らの困っている内容を聞き出します(例:おすすめの寿司のお店はどこ?など)。

外国人の方を目的地までご案内いたしますが、我々はプロではないので、あくまでも自分たちの知っている範囲でorGoogle やスマートフォンで調べて分かる範囲でお伝えしています。

3 アプリを作成しようと思ったきっかけ

きっかけとしては2つあります。

一つ目は人数集計作業の簡素化のためです。

活動中に案内した方々の国籍や人数等は下記のシートに記入しています。

図1.png

一番最後に活動で関わった人達の合計人数を大陸別に集計するのですが、この作業を簡素化することを目指しました。

2つ目は、活動中に撮影した写真を参加者の間で共有するツールが必要だったためです。

今までは参加者同士でLINEのアカウント交換をしてLINE上で写真の共有を行っていましたが、LINEに頼らなくても写真を共有できるツールが必要でした。

以上の2点からask me!のオリジナルのアプリを開発することにしました!

4 UI

開発したアプリの主なUIを下記に示します。

図2.png

①ログイン画面

②活動する前に参加者や今日の目標等を登録する画面

③統計シート入力画面

④案内した合計人数を確認できる画面

⑤サイドバーメニュ

⑥写真共有機能画面

5 アプリの機能説明

①ログイン画面
ask me! appはask me!の活動参加者が使うことを想定していますので、一番最初にログイン画面を設けています。

②活動する前に参加者や本日の目標等を登録する画面
統計シートでは活動前に参加者の名前や本日の活動目標を記載していましたが、それをスマホアプリでも記入できるようにする画面です。

③統計シート入力画面
活動中に案内した外国人の国籍や人数を記入する画面です。

④案内した合計人数を確認できる画面
活動中に案内した人数一覧を確認できる画面です。

⑤サイドバーメニュ
ask me!のHPやSNSおよび写真共有機能画面へのリンクを貼っています。

⑥写真共有機能画面
活動中に撮影した写真はこの画面で参加者と共有します。

6 技術選定

ask me! app作成にはFlutterを利用しました。理由は同じソースコードでiOSとAndroidの開発が同時にできるからです。

バックエンドにはFirebaseを利用しました。

7 技術習得方法

私はFlutter初心者だったため、まずは本で勉強しました。Flutterに関する本は少ないのですが、下記の本で勉強しました。

Android/iOSクロス開発フレームワーク:https://books.rakuten.co.jp/rk/8f657c363ada3ef9bd62b9abafc2fa28/

全部読んでいると非常に時間がかかるので、最初の2章までを読みました。2章までを読むだけでも、基本的な内容はわかると思います。

なお、Flutterで分からないことを知らべる際は英語の記事が多いので、英単語を入力して調べるといいとおもいます。

実現したい機能を英語で入力するとサンプルコードが出てくるのでそれを見て勉強していました。
(例:写真共有機能なら「Flutter photo share」で検索してみる、など)

8 まとめ

今回はask me! appの簡単な紹介をさせていただきました。

次回以降は各画面についてソースコード例と共にどのように実装したのかを記載したいと思います。

また、興味があれば読んでいただけると幸いです(次回は8月下旬に投稿予定です)。

アプリは下記から

Android: https://play.google.com/store/apps/details?id=com.askme.flutter_app4
iOS : https://apps.apple.com/us/app/ask-me/id1481671421?l=ja&ls=1

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

coroutines x Retrofitで CallAdapterを使いAPIエラーレスポンスをデコードする

はじめに

APIのエラーレスポンスの型が統一されている場合, エラーレスポンスの型を定義し, デコードを共通化して行いたくなります.
今回はRetrofitCallAdapterを使用して, APIエラー時の挙動を(HttpExceptionではなく) Custom Exceptionを吐くよう変更する方法をメモしておきます.

公式のサンプルにも同じ目的のCallAdapterがありますが, coroutinesの場合必要な定義が少し変わるのでメモしておきます.

CallAdapter.Factory#getの処理

suspend関数を使う場合, CallAdapter.Factory#getreturnTypeCallであることを確認する必要があります.

class ErrorHandlingCallAdapterFactory : CallAdapter.Factory() {
    override fun get(
        returnType: Type,
        annotations: Array<Annotation>,
        retrofit: Retrofit
    ): CallAdapter<*, *>? {
        // 返り値がCall<..>である場合のみ適用する.
        // 注: 「nullを返す」 == 「このAdapterはこの関数に適用できない」を表す
        if (getRawType(returnType) != Call::class.java) {
            return null
        }
        require(returnType is ParameterizedType) { "Call return type must be parameterized as Call<Foo> or Call<? extends Foo>" }
        ...
    }
}

coroutinesを使用する場合, 返り値の型はCallで括らず, suspend fun sampleApi(): Hoge のように直接定義すると思います.
そのため一瞬解せない気持ちになりそうですが, この時内部では以下のような処理が行われています(参考).

  • もしsuspend関数であれば, HogeCall<Hoge> に変換する.
  • Call<Hoge>に対応できるCallAdapter.Factoryを探索する

これらから, suspend関数のreturnTypeHogeではなくCall<Hoge>が与えられます. そのため, suspend関数に対応してCallAdapterを設定したい場合は, returnTypeCall<..>である時にインスタンスを返すように実装します.

CallAdapterの型

suspend関数を使う場合, Callを返すAdapterを適用します.

    class Adapter<T> : CallAdapter<T, Call<T>> {
        override fun responseType(): Type = ...
        // Call<T>を受け取り, Call<T>を返すAdapter.
        override fun adapt(call: Call<T>): Call<T> = ..
    }

前節と同様に, retrofit内部では以下のような処理がされています(参考).

  • CallAdapter#adoptの返り値としてCall<Hoge>を受け取る
  • それをContiuation (suspend関数) を介してHoge に変換して返す

そのため, 「(Call<T>を受け取り, )Call<T>を返す」型である CallAdapter<T, Call<T>>が正しい型となります.

Executor

suspend関数を使う場合, 通常callbackをメインスレッドで行う必要がないため, Retrofit#callbackExecutorは使用しません.
メインスレッド以外を設定していたり, 特別な事情がある場合は使用した方が良さそうです.

サンプル

以下がコード例です. Custom Exceptionにデコードできないケースではデフォルトと同じ挙動をさせるような実装になっています.

class ErrorHandlingCallAdapterFactory : CallAdapter.Factory() {
    override fun get(
        returnType: Type,
        annotations: Array<Annotation>,
        retrofit: Retrofit
    ): CallAdapter<*, *>? {
        if (getRawType(returnType) != Call::class.java) {
            return null
        }
        require(returnType is ParameterizedType) { "Call return type must be parameterized as Call<Foo> or Call<? extends Foo>" }
        return Adapter<Any>(getParameterUpperBound(0, returnType))
    }

    private class Adapter<T>(private val responseType: Type) : CallAdapter<T, Call<T>> {
        override fun responseType(): Type = responseType
        override fun adapt(call: Call<T>): Call<T> = ErrorHandlingCall(call)
    }
}



class ErrorHandlingCall<T>(
    private val delegate: Call<T>
) : Call<T> by delegate {
    override fun enqueue(callback: Callback<T>) =
        delegate.enqueue(object : Callback<T> by callback {
            override fun onResponse(call: Call<T>, response: Response<S>) {
                if (response.isSuccessful) {
                    callback.onResponse(this@ErrorHandlingCall, response)
                    return
                }
                val errorBody = response.errorBody()
                if (errorBody == null || errorBody.contentLength() == 0L) {
                    callback.onResponse(this@ErrorHandlingCall, response)
                    return
                }

                try {
                    // Custom Exceptionにデコード・変換する
                    val exception = convertToCustomException(response)
                    callback.onFailure(this@ErrorHandlingCall, exception)
                } catch (ex: Exception) {
                    callback.onResponse(this@ErrorHandlingCall, response)
                }
            }
        })
}

これをRetrofit.Builderに渡せば完成です.

interface Service {
    @GET("..sample..")
    suspend fun sample(): Sample
}

Retrofit.Builder()
    .addCallAdapterFactory(ErrorHandlingCallAdapterFactory())
    .build()
    .create(Service::class.java)

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ChannelTalkのiOS、Androdアプリへの導入方法

MAMORIOにおけるチャットツールの利用とChannelTalkへの移行

落とし物防止タグのMAMORIOはIoTタグとアプリを連携させる複雑さの問題からユーザーからの問い合わせが多く、2018年頃からサポートの体験の質を向上させるためにIntercomというチャットツールを導入していた。

しかし、米国製で主に欧米や中南米諸国をマーケットとしているIntercomは基本的に英語話者向けで管理コンソールもIntercom社員とのやりとりも英語であり、また(便利そうな)自然言語処理を用いたチャットボット機能も一向に日本語対応する気配がなかったため、韓国製でBotやマーケティング機能のコストが安く、かつ日本語圏を優先してくれるChannelTalkへ移行したところオペレーターによるサポート負荷を半減させることに成功した

以下は弊社オペレーターが書いた記事: https://note.com/tanakosan0508/n/n99af82341ba0

導入方法

導入ガイド: https://developers.channel.io/docs

SDKを使用するには以下2つを行う必要がある。

  • 1. Plugin Keyの取得
    • スクリーンショット 2020-08-03 18.59.53.png
  • 2. push通知用証明書のアップロード
    • スクリーンショット 2020-08-03 19.36.16.png

また注意点としては、Androidでは以下のようにgradleに追加するだけでサクッとパッケージをインストールできるのだが、

dependencies{
    //Cannel Talk
    implementation 'com.zoyi.channel:plugin-android:7.1.3'
    implementation 'com.zoyi.channel:plugin-android-fcm:7.1.3'
}

iOSの場合、弊社のようにCarthageで管理を行っていると以下のようにChannelIOが依存している大量のライブラリを追加する必要がある。

image.png

利用方法

ChannelTalkの動作には以下のサイクルがある。

1. initalize

ChannelTalkがアプリの状態を監視できるようにする(やり忘れてもコンパイルは通りその後のコードも動くが、バックグラウンド時のプッシュ通知によるチャットの応答などが一切機能しなくなるので注意)。

iOS

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Override point for customization after application launch.
    ChannelIO.initialize(application)
    return true
  }

Andorid

public class MyApplication extends Application {
  @Override
  public void onCreate() {
    super.onCreate();

    ChannelIO.initialize(this);

    // TODO : Your code   
  }
}

2. 遠隔プッシュ通知登録

ChannelTalkのサーバーにデバイスをプッシュ通知の受け手として登録する
これをおこなわないとユーザーがバックグラウンドにしてしまったあとに返信を行ってもプッシュ通知が届かない。

iOS

func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
  ChannelIO.initPushToken(deviceToken)
}

Android
くわしくはこちら

public class MyActivity extends AppCompatActivity {

    @Override
    public void onCreate() {
        super.onCreate();

        // TODO : Your code
        // ...
        // ...
        ChannelIO.handlePushNotification(this);
    }
}

3. bootとshow

bootはユーザーの情報をチャネルトークに渡してユーザー情報を更新し、セッションを貼る処理である。
bootはチャットコミュニケーションを行うためには必ず行わなければならず、viewの描画が終わったタイミングでbootを行ったあと、コールバックでshowコマンドによりランチャーを表示することが推奨されている。 (iOSの場合viewDidAppear)

iOS

let settings = ChannelPluginSettings()
settings.pluginKey = "YOUR_PLUGIN_KEY"
settings.memberId = "memberId"
let profile = Profile()
profile.set(name: "Jason")
ChannelIO.boot(with: settings, profile: profile) { (status, guest) in
    if status == .success {
         ChannelIO.show(animated: true)
    } else {
         //異常処理
    }
}

なお、Profileは非必須でログイン前の匿名ユーザーともチャットを行うことができる。

let settings = ChannelPluginSettings()
settings.pluginKey = "YOUR_PLUGIN_KEY"
ChannelIO.boot(with: settings, profile: profile) { (status, guest) in
  print(guest) //=> nil
}

Android

ChannelPluginSettings settings = new ChannelPluginSettings(YOUR_PLUGIN_KEY);
settings.setUserId(uniqueUserId);

Profile profile = Profile.create()
      .setName(userName)
      .setEmail(userEmail)
      .setProperty("HomePage", "www.zoyi.co");

// If you register the listener, it receives the boot status.
// Booting without user information
  ChannelIO.boot(ChannelPluginSettings settings, OnBootListener listener);

4. hide

iOSではチャネルトークのランチャーと返信のバナーはWindowの最前面に表示されるので、特定のページのみでチャットを表示し他のページで表示したくない時は明示的にhideし隠さなければならない。

iOS

override func viewWillDisappear(_ animated: Bool) {
   ChannelIO.hide(animated: true)
}

5. shutdown

ChannelTalkは一度bootした後コネクションを貼ってメッセージを待ち続けるため、ユーザーがそのアカウントでのアプリの使用をやめる際は明示的にshowdownコマンドを実行しなければならない。

iOS

func logout() {
   ChannelIO.shutdown();
}

メリット

1. 接客チャットの料金が訪問者数5000人超で月9000円

ChannelTalkも接続したユーザー数による従量課金だが、上限が5001人で終わる。

image.png

2. 日本語の接客botを作成できる

ChannelTalkを導入する最大のメリットは、選択肢式の接客ボットを日本語で複数種類手軽に作成できることである。
MAMORIOで日本語のbotを導入した結果、オペレーターへ直接話しかけるユーザーの割合が半減しサポートチームの負荷を激減させることに成功した。

スクリーンショット 2020-08-03 20.43.41.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

オープンソースのAndroid(AOSP)にGooglePlayStoreを入れる

Step1 端末にあったファイルをダウンロードする

PlayStoreを利用するには、以下の3つのパッケージが必要となります。

  • GoogleServicesFramework
  • Phonesky(GooglePlayストア)
  • PrebuiltGmsCore(GooglePlay開発者サービス)

これらはOpen GAPPSからダウンロードすることができます。

手持ちのAndroidに合わせてPlatform,Androidを選択しましょう。
Variantはpicoあたりで大丈夫です。

CPUを確認するコマンド(armeabi-v7aであれば32bitARMです)
adb shell getprop ro.product.cpu.abi

バージョンを確認するコマンド
adb shell getprop ro.build.version.release

Step2 必要な.apkファイルを揃える

ダウンロードしたファイルを解凍すると、Coreフォルダ内に以下の3つのファイルがあると思います。

  • gmscore-arm.tar.lz
  • gsfcore-all.tar.lz
  • vending-arm.tar.lz

これらをlzipで展開します。
tar --lzip -xvf [ファイル名]
または
lzip -d -c [ファイル名] | tar xvf -

展開すると、nodpiフォルダ内に.apkファイルがあるので、それらを別フォルダにまとめておきましょう。

Step3 システムアプリとしてインストールする

1) システムへの書き込み権限を与える

通常の方法ではインストールができないので、手動でインストール作業をします。
まずどこのパーティションが/systemに割り当てられているかを確認します。
adb shell cat /proc/mounts | grep system

すると以下のように表示されると思います。
/dev/block/mmcblk2p5 on /system type ext4 (ro,seclabel,relatime,data=ordered)

書き込み権限がないようであれば(roと書いてある)、書き込み権限をつけてマウントしなおします。
adb shell mount -o rw,remount /system

2) フォルダを作成し、パッケージを入れる

/system/priv-appに、以下の3つのフォルダを作成します。

  • GoogleServicesFramework
  • Phonesky
  • PrebuiltGmsCore
adb shell mkdir /system/priv-app/GoogleServicesFramework
adb shell mkdir /system/priv-app/Phonesky
adb shell mkdir /system/priv-app/PrebuiltGmsCore

Step2で抽出したパッケージをこのフォルダに入れていきます。

adb push PrebuiltGmsCore.apk /system/priv-app/PrebuiltGmsCore/
adb push GoogleServicesFramework.apk /system/priv-app/GoogleServicesFramework/
adb push Phonesky.apk /system/priv-app/Phonesky/Phonesky.apk

フォルダとファイルの権限を絞ります。

adb shell chmod 755 /system/priv-app/GoogleServicesFramework
adb shell chmod 755 /system/priv-app/Phonesky
adb shell chmod 755 /system/priv-app/PrebuiltGmsCore

adb shell chmod 644 /system/priv-app/GoogleServicesFramework/GoogleServicesFramework.apk
adb shell chmod 644 /system/priv-app/Phonesky/Phonesky.apk
adb shell chmod 644 /system/priv-app/PrebuiltGmsCore/PrebuiltGmsCore.apk

Step4 端末での設定

最後に端末を再起動すると、インストールされたアプリが確認できると思います。
adb reboot

あとは端末上で、先ほどインストールしたアプリに権限をつけていきます。
Settings>Apps(設定>アプリ)に移動し、右上のメニューからシステムアプリを表示させましょう。
以下のものがインストールされていると思います。

  • Google Play Store(Google Play ストア)
  • Google Play Services(Google Play開発者サービス)
  • Google Services Framework

それぞれのアプリの権限を全て許可させれば終了です。

その他

場合によっては、GoogleLoginService(Googleアカウントマネージャー)が必要となるかもしれません。
この場合Step2でgsflogin-all.tar.lzを展開し、あとは同様の手順を辿るとよいでしょう。

このドキュメントは以下のサイトを参考にしました。
Install Google App on AOSP Build
Install Google Play Store on Android 7.1.1(API 25) emulator

ちなみに、GMS搭載のライセンスはGoogleへの申請が必要となります。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Androidアプリ開発探求記(その4)

概要

今回は git の .ignore に関して、探求してみたいと思います。

やること

Android Studio で作成したデフォルトの .ignore ファイルに対して、以下のことをしてみようと思います。

  • 現在の実験コード1上にフィルタリング対象が存在する項目に対してコメントを追加
  • 現在の実験コード上にフィルタリング対象が存在しない項目を削除

※ 自明と思われるものについては例外的に .ignore に含めていこうと思います。2

背景

.ignore に関してはネット上に様々な情報が存在し、.ignore.io のような便利な自動生成ツールも存在します。

しかし、状況によっては必要となるグレーゾーンのファイルまでバッサリ切り捨ててしまっているため、弊害もあります。

例えば、開発プロジェクトでスペルチェック用辞書を共有する方法では辞書ファイルは idea/dictionaries フォルダ以下に配置されますが、.gitignore.io [AndroidStudio] ではバッサリ切り捨てられてしまっています。

実際問題としては、Android Studio 上での辞書共有にはいろいろと欠点もあるため、デフォルトでバッサリ切り捨ててしまったほうが安全だと思います。しかし、最初から切り捨ててしまうと、存在自体を気づけないという弊害があり、これは探求記的には許容できません。

ということで、今後 .ignore に関しては、全項目を把握していこうと思います。

Android Project

Android Studio で作成したデフォルトの .ignore ファイルは2つあります。

◆ Root Project

/build

◆ Sub Project

*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx

.gitignore を削除してみる

.gitignore を削除し、Reimport gradle project, Rebuild Project を行い、差分を確認してみます。

$ git status
On branch comment_on_dot_ignore_files
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        deleted:    .gitignore
        deleted:    app/.gitignore

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        .gradle/
        .idea/caches/
        .idea/libraries/
        .idea/modules.xml
        .idea/modules/
        .idea/workspace.xml
        app/build/
        local.properties

必須の項目だけ復活させる

現状でフィルタが必要、もしくは自明の項目だけを復活させてみます。

### Android Studio ###

# IDEAのモジュール定義ファイル。自動生成されるものなので、commit されるべきではない。
*.iml

# IDEローカルのキャッシュ情報だと思われる。commit されるべきではない。
/.idea/caches

# IDE上で利用されるライブラリの情報だと思われる。自動制裁される情報であり、共有の必要もないので、commit されるべきではない。
/.idea/libraries

# IDE上のモジュール構成情報だと思われる。IDEの利用状況により異なる情報であり、共有の必要もないので、commit されるべきではない。
/.idea/modules.xml

# IDEローカルの情報なので commit されるべきではない。
/.idea/workspace.xml

# This file is automatically generated by Android Studio. This file should *NOT* be checked into Version Control Systems, as it contains information specific to your local configuration.
/local.properties

### Gradle ###

# gradle の work 領域。gradle により自動生成されるものなので commit されるべきではない。
.gradle

# gradle の build 成果物用フォルダ。build により自動生成されるものなので commit されるべきではない。
/build

### MAC ###

# MAC OS上の不可視ファイル。commit されるべきではない。
.DS_Store

削除された項目

/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/captures
.externalNativeBuild
.cxx

まとめ

今回は、Android Studio で作成したデフォルトの .ignore ファイルに対して、現時点で必須なものだけに対してコメントを付与し、そうでないものは削除してみました。

今後も同様の対応をしていこうと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Androidアプリ開発探求記(その3)

概要

前回利用した Android project を起動したら、なにやら .idea/gradle.xml が変更されていました。

タイムスタンプを確認したところ、どうやら Android Studio でプロジェクトを開いたタイミングで変更が入ったっぽいです。

<component name="GradleMigrationSettings" migrationVersion="1" />

対応

特に問題なさそうなので、普通に commit しました。
diff: GitHub

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Flutterの状態管理】テーマ切替&多言語化 〜provider,BLoC,reduxの3パターンで実現してみる③

いよいよ本シリーズの最終篇になります。
本篇は、アプリのテーマ(Theme)切替、多言語化を通じてFlutterの状態管理のreduxパターンを説明します。

三、reduxでテーマ切替&多言語化

flutter_redux: ^0.6.0

Blocと同じのようにreduxにも3つのポイントがある。それは 状態StateアクションAction変化させる者Reducer

reduxは封建制とある程度似ている。天子が、土地を諸侯に分与し、諸侯はまたそれぞれ領内の管理を大夫に委託する。各々が責任を取って行動する。reduxにおいて、全ての状態はstoreによって集中管理し、天子AppStateから階層的に分配していく。

Simulator Screen Shot - iPhone 11 Pro - 2020-08-02 at 23.52.34.png Simulator Screen Shot - iPhone 11 Pro - 2020-08-02 at 23.52.56.png Simulator Screen Shot - iPhone 11 Pro - 2020-08-02 at 23.53.19.png Simulator Screen Shot - iPhone 11 Pro - 2020-08-02 at 23.53.25.png Simulator Screen Shot - iPhone 11 Pro - 2020-08-02 at 23.53.41.png Simulator Screen Shot - iPhone 11 Pro - 2020-08-02 at 23.53.47.png

1.redux状態、アクション、変化させる者の準備

天子

appState_redux.dart
import 'themeState_redux.dart';
import 'localState_redux.dart';

class AppState {
  final ReduxThemeState themeState;//テーマ状態
  final ReduxLocaleState localeState;//言語状態
  AppState({this.themeState, this.localeState});

  factory AppState.initial()=> AppState(
      themeState: ReduxThemeState.initial(),
      localeState: ReduxLocaleState.initial()
  );
}

//集中的に状態を分配
AppState appReducer(AppState prev, dynamic action)=>
    AppState(
      themeState:themeDataReducer(prev.themeState, action),
      localeState: localReducer(prev.localeState, action),);

諸侯① theme

themeState_redux.dart
import 'package:flutter/material.dart';
import 'package:redux/redux.dart';

//テーマ状態の切り替え
class ReduxThemeState extends ChangeNotifier {
  ThemeData themeData; 
  int colorIndex; 

  ReduxThemeState(this.themeData,this.colorIndex);

  factory ReduxThemeState.initial()=> ReduxThemeState(ThemeData(primaryColor: Colors.green),1);
}

//テーマアクションの切り替え
class ActionSwitchTheme {
  final ThemeData themeData;
  final int colorIndex;
  ActionSwitchTheme(this.themeData,this.colorIndex);
}

//変化させる者の切り替え
var themeDataReducer = TypedReducer<ReduxThemeState, ActionSwitchTheme>((state, action) =>
    ReduxThemeState(action.themeData,action.colorIndex));

諸侯② local

localState_redux.dart
import 'package:flutter/material.dart';
import 'package:redux/redux.dart';

//言語状態の切り替え
class ReduxLocaleState{
  Locale locale;
  ReduxLocaleState(this.locale);
  factory ReduxLocaleState. initial()=> ReduxLocaleState(Locale('ja', 'JP'));
}

//言語アクションの切り替え
class ActionSwitchLocal {
  final Locale locale;
  ActionSwitchLocal(this.locale);
  factory ActionSwitchLocal.ja()=> ActionSwitchLocal(Locale('ja', 'JP'));
  factory ActionSwitchLocal.en()=> ActionSwitchLocal(Locale('en', 'US'));
}

//変化させる者の切り替え
var localReducer = TypedReducer<ReduxLocaleState, ActionSwitchLocal>((state,  action) => 
    ReduxLocaleState(action.locale,));

2.reduxプロパティーの使用

main.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; //providerパターンのpubspec.yaml配置を参照
import 'home_page.dart';
import 'provider/I18nDelegate.dart'; //providerパターン紹介時のファイル使用
import 'package:flutter_redux/flutter_redux.dart';
import 'package:redux/redux.dart';
import 'redux/appState_redux.dart';

void main() {
  runApp(Wrapper(child: MyApp()));
}

class Wrapper extends StatelessWidget {
  final Widget child;
  Wrapper({this.child});

  @override
  Widget build(BuildContext context) {
    //StoreProviderによってラップする、storeプロパティーでAppStateを配置
    return StoreProvider(
        store: Store<AppState>(appReducer, initialState: AppState.initial()),
        child: MyApp());
  }
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StoreBuilder<AppState>(builder: (context,store) => MaterialApp(
                  title: "状態管理Demo",
                  localizationsDelegates: [
                    GlobalMaterialLocalizations.delegate,
                    GlobalWidgetsLocalizations.delegate,
                    I18nDelegate.delegate //言語デリゲート シリーズ初編のproviderパターンのファイルを利用
                  ],
                  locale: store.state.localeState.locale,
                  supportedLocales: [store.state.localeState.locale],
                  debugShowCheckedModeBanner: false,
                  home: HomePage(),
                ));
  }
}

状態の切り替えは store.dispatch(~action) を使う

home_page.dart
import 'package:flutter/material.dart';
import 'provider/i18n.dart'; //providerパターン紹介時のファイル使用
import 'package:flutter_redux/flutter_redux.dart';
import 'redux/appState_redux.dart';
import 'redux/themeState_redux.dart';
import 'redux/localState_redux.dart';

class HomePage extends StatefulWidget {
  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    var screenWidth = MediaQuery.of(context).size.width;
    var statusBarH = MediaQuery.of(context).padding.top;
    var naviBarH = kToolbarHeight;

    //StoreBuilder<AppState>(builder: (context,store) を通じて状態ゲット
    return StoreBuilder<AppState>(
      builder: (context, store) => Scaffold(
        appBar: AppBar(
          backgroundColor: store.state.themeState.themeData.primaryColor,
          title: Text(
            I18N.of(context).title,
            style: TextStyle(color: Colors.white),
          ),
        ),
        drawer: Drawer(
          child: Column(
            children: <Widget>[
              Container(
                width: screenWidth,
                height: statusBarH + naviBarH,
                color: store.state.themeState.themeData.primaryColor,
              ),
              SizedBox(height: 10.0),
              Row(
                children: <Widget>[
                  Padding(padding: EdgeInsets.all(10)),
                  Wrap(
                    children: <Widget>[
                      RaisedButton(
                        color: Colors.green,
                        onPressed: () {
                          //イベントトリガー、重要
                          store.dispatch(ActionSwitchTheme(
                              ThemeData(primaryColor: Colors.green), 1));
                        },
                        child: Text(
                          I18N.of(context).greenBtn,
                          style: TextStyle(color: Colors.white),
                        ),
                        shape: CircleBorder(),
                      ),
                      RaisedButton(
                        color: Colors.red,
                        onPressed: () {
                          store.dispatch(ActionSwitchTheme(
                              ThemeData(primaryColor: Colors.red), 2));
                        },
                        child: Text(
                          I18N.of(context).redBtn,
                          style: TextStyle(color: Colors.white),
                        ),
                        shape: CircleBorder(),
                      ),
                      RaisedButton(
                        color: Colors.blue,
                        onPressed: () {
                          store.dispatch(ActionSwitchTheme(
                              ThemeData(primaryColor: Colors.blue), 3));
                        },
                        child: Text(
                          I18N.of(context).blueBtn,
                          style: TextStyle(color: Colors.white),
                        ),
                        shape: CircleBorder(),
                      ),
                    ],
                  ),
                ],
              ),
              Divider(),
              SizedBox(
                height: 10,
              ),
              Row(
                children: <Widget>[
                  Padding(padding: EdgeInsets.all(10)),
                  SizedBox(
                    width: 15,
                  ),
                  RaisedButton(
                    color: store.state.themeState.themeData.primaryColor,
                    onPressed: () {
                      store.dispatch(ActionSwitchLocal.ja());
                    },
                    child: Text(
                      "日本語",
                      style: TextStyle(color: Colors.white),
                    ),
                  ),
                  SizedBox(
                    width: 15.0,
                  ),
                  RaisedButton(
                    color: store.state.themeState.themeData.primaryColor,
                    onPressed: () {
                      store.dispatch(ActionSwitchLocal.en());
                    },
                    child: Text(
                      "English",
                      style: TextStyle(color: Colors.white),
                    ),
                  ),
                ],
              ),
            ],
          ),
        ),
        body: ListView(
          children: <Widget>[
            Center(
              child: Column(
                children: <Widget>[
                  SizedBox(height: 10.0),
                  Container(
                    width: 200,
                    height: 300,
                    color: store.state.themeState.themeData.primaryColor,
                  ),
                  SizedBox(height: 10.0),
                  Text(
                    I18N.of(context).analects,
                    style: TextStyle(
                        color: store.state.themeState.themeData.primaryColor,
                        fontSize: 18.0,
                        fontWeight: FontWeight.bold),
                  ),
                  SizedBox(height: 30.0),
                  FloatingActionButton(
                      onPressed: () {},
                      backgroundColor:
                          store.state.themeState.themeData.primaryColor,
                      child: Icon(Icons.check)),
                ],
              ),
            )
          ],
        ),
      ),
    );
  }
}


まとめ

いかがでしたか。本篇をもってflutterの状態管理シリーズを完了とします。総じて言えば、3パターンともに構造的に、実現方式的に似ているようなところがあります。providerはflutter公式が推奨するパターンとして使っても無難でしょう。Streamに対する理解を深めれば、Blocの使用は少し上級感が感じられます。reduxはコード量が少なく、構造もはっきりしており、個人的には好きです。
どのパターンを採用するのかは正解がなく、プロジェクトのニーズに合わせて決めておけば良いでしょう。また、状態を変更する必要のあるwidgetがそれほど無ければ、基礎のsetState()というapiを使っては分かりやすいでしょう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む