アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

Android版いこーよの中身。Jetpack Navigation編

Androidアプリエンジニアのhondaです。

Android版いこーよではNavigation componentを導入しています。 今回はNavigation componentで画面遷移を実装している際のTIPSを紹介したいと思います。 このブログではNavigation componentについては深く説明しません。 Navigation componentについては下記の公式ページを御覧ください。

developer.android.com

NavigationのstartDestinationを切り替える

Android版いこーよでは今まで1画面1Activityという構成で作られていました。 Navigation componentのstableとなる1.0.0がリリースされてから新規機能などで追加される画面からNavigation componentを導入しています。 今年の初旬にいこーよではいこーよプレミアムという機能がリリースされました。Androidでも実装され、その部分でNavaigation componentも使われています。 iko-yo.net

いこーよプレミアムの場合、プレミアム未加入と加入済みで機能としての最初の画面を切り替える必要がありました。 f:id:kou_hon:20210622064852p:plain これを解決するために今回はプレミアム画面表示時にNavigation componentのナビゲーショングラフのstartDestinationを動的に変更する方法をとりました。

class SampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(this.viewBinding.root)

        val startDestinationId = this.intent.getIntExtra("startDestinationId", 0)
        val navController = this.supportFragmentManager.findFragmentById(R.id.fragmentContainerView)!!.findNavController()
        val navGraph = this.navController.navInflater.inflate(R.navigation.premium_navigation)
        navGraph.startDestination = startDestinationId
        navController.setGraph(navGraph)
    }
}

上記コードの用にまずはナビゲーショングラフが設定されるFragmentContainerViewからNavControllerを取得します。 次に取得したNavControllerからナビゲーショングラフのリソースIDを元にNavGraphを取得します。 最後にNavGraphのstartDestinationに遷移先のFragmentIDを設定して、NavControllerに対してNavGraphを再設定します。 これでSampleActivityを呼び出す側のActivityから画面を切り替えることができるようになりました。

Upボタンを表示させたい

あと、今回仕様としてプレミアム機能の画面でstartDestinationとなる画面でもUPボタンを表示したいというものがありました。 素直に実装するとstartDestinationとなる画面にはUPボタンが表示されません。

f:id:kou_hon:20210625094957p:plain:w250

Upボタンを表示するために以下の方法をとりました。

class SampleFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val toolbar = view.findViewById<MaterialToolbar>(R.id.toolbar)
        val navController = this.findNavController()
        val appBarConfiguration = AppBarConfiguration.Builder(emptySet())
            .setFallbackOnNavigateUpListener {
                this.requireActivity().finish()
                true
            }
            .build()
        toolbar.setupWithNavController(navController, appBarConfiguration)
    }
}

ポイントはAppBarConfiguration.Builderの引数でemptySet()を渡しているところです。 AppBarConfiguration.Builderの引数でFragmentIDを渡すと渡されたFragmentIDをトップレベル画面と判断して、UPボタンが表示されなくなります。 なのでここでemptySet()を渡すことでどの画面でもUPボタンが表示されるようになります。 最後にsetFallbackOnNavigateUpListenerでSampleActivityをfinishして呼び出し側のActivityに戻るようにします。

現場からは以上です。

Kotlinのスコープ関数との付き合い方

Androidアプリ開発担当のhondaです。 皆さんKotlinのスコープ関数使ってますか? 好きなスコープ関数はalsoです。

結論

Android版いこーよではスコープ関数はlet、alsoそしてrunを使うようにしています。 今回はなぜそのようにしているのか解説したいと思います。

スコープ関数のおさらい

Kotlinのスコープ関数とはKotlinの標準ライブラリに含まれている関数です。 オブジェクトに対してその関数を呼び出すと一時的に形成されたスコープの中で名前をつけることなくオブジェクトに参照できます。 それによりコードをより簡潔で読みやすいものにすることができます。 現在定義されているスコープ関数はlet, run, with, apply、そしてalsoです。*1

Person("Alice", 20, "Amsterdam").let {
    // 中括弧の中でPersonオブジェクトに"it"で参照することができます。
    println(it)
    it.moveTo("London")
    it.incrementAge()
    println(it)
}

withとapplyを使わない理由

それは、オブジェクトへの参照に"this"を使うからです。 これはwithとapplyではオブジェクトをラムダレシーバーとして参照しているからですがこの場合、クラスへの参照の"this"と混合してしまって誤認の可能性があると感じました。 withとapplyの"this"を省略することもできますが、レシーバのメンバと外部のオブジェクトや関数との区別がつきにくくなる可能性もあります。 よって、withとapplyは使わないことに決めました。 また、letとalsoはデフォルトでは"it"でオブジェクトに参照できますが、カスタム名をつけることができるのでスコープの外部と内部を区別しやすくなります。

fun getRandomInt(): Int {
    return Random.nextInt(100).also { value ->
        // カスタム名"value"でオブジェクトを参照することができます。
        writeToLog("getRandomInt() generated value $value")
    }
}

val i = getRandomInt()

runは?

runもwith、apply同様にオブジェクトへの参照は"this"です。 以下のような場合に使うのを許可しています。

エルビス演算子と併用することによって、sample()の戻り値がnullだった場合にrunのスコープ内の処理を実行することができます。 またこの場合、runのレシーバーはクラスになるのでスコープの外と内で"this"は両方ともクラスを参照することになるので誤認の可能性はなくなります。

class Foo {
    fun bar() {
        sample()?.let {
              // sample()の戻り値がnotnullだった場合の処理
         } ?: run {
             // sample()の戻り値がnullだった場合の処理
         }
    }
}

現場からは以上です。

Androidアプリの配布にGitHub Actionsを使ってみた。

adventar.org

これはactindi Advent Calendar 2019、1日目の記事です。
こんにちは!Androidアプリエンジニアのhondaです。
今日でactindi勤続4年になりました。
今回は開発中のアプリをメンバーに配布するためにGitHub Actionsを使ってみたときのことをつらつらと書きたいと思います。

GitHub Actions、使ってよかった。

Android版いこーよでは今までCIでbitriseを使っていました。
もともと、GitHub Actionsを使ってみた理由がビルド時間が短縮できるかを検証するためでした。 ビルド時間を比較したところ2分ほど短縮することができました。
また、bitriseよりGitHub Actionsのほうがビルドが安定しているようなのでとても好印象でした。
Android版いこーよでは今後もGitHub Actionsを使っていこうと思っています。

GitHub Actionsとは?

GitHubアクションは、ワークフローのすべてのステップに統合された強力な実行環境を備えています。任意のジョブを実行するアクションを検出、作成、共有し、それらを組み合わせてワークフローをカスタマイズできます。

公式のヘルプページより抜粋したものを翻訳しました。

つまり、GitHubのリポジトリ上でのアクションをトリガーにワークフローを実行して、ワークフローの中でビルドして配信したり、LintチェックしてPRに結果を載せたり、テストを実行したりすることができるCI環境です。

参考にしたもの

speakerdeck.com

サクッとGitHub ActionsのWorkFlowを定義する一連の流れを理解できます。

help.github.com

公式のヘルプページです。 日本語ドキュメントもありますが、まだ途中だったりするので英語ページでGoogle Translationで翻訳したほうが読みやすいような気がします。

使ってみた

1.GitHubのRepositoryのトップページのActionsタブをクリック。

f:id:kou_hon:20191130121221p:plain

2.New workflowをクリック。

f:id:kou_hon:20191130121248p:plain

3.「Popular continuous integration workflows」の中に「Android CI」というworkflowが用意されているのでそれの「Set up this workflow」をクリック

f:id:kou_hon:20191130121418p:plain

4.workflowが定義されているymlファイルの編集画面が表示されるので、右上の「Start commit」をタップするとmasterにymlファイルがプッシュされます。

f:id:kou_hon:20191130121448p:plain

5.デフォルトだとworkflowが実行される条件が on: [push]となっています。つまり、「Start commit」をタップするとworkflowが実行、ビルドが始まります。

6.最後に今回はデバッグビルドを試したので最後のビルドコマンドは ./gradlew assembleDebug に変更します。

ここまでやって、冒頭のビルド時間の短縮を確認することができました。
ですが、このままではいろいろ足りないのでworkflowを改修していきます。

出来上がったAPKファイルをDeployGateに配信したい。

Android版いこーよではデバッグ版APKファイルをDeployGateにアップロードして関係者に配布しています。GitHub Actionsでも同じように配信したいと思います。
runコマンドでシェルを実行できます。

ドキュメント

help.github.com

WorkFlowに追加

ymlファイルの最後に以下を追加します。

    - name: Distribute App
      run: |
        curl \
         -F "token=${{secrets.DEPLOYGATE_TOKEN}}" \
         -F "file=@app/build/outputs/apk/debug/app-debug.apk" \
         -F "message=https://github.com/${{secrets.GITHUB_USER}}/${{secrets.GITHUB_REPOSITORY}}/commit/`git rev-parse --short $GITHUB_SHA`" \
         -F "distribution_name=$GITHUB_HEAD_REF" \
         https://deploygate.com/api/${{secrets.DEPLOYGATE_USER}}/actindi/apps

WorkFlowが終了したらslackに通知したい

今回は以下の2つを使ってみました。

Slack Notify

github.com

  • slackのWebHookで連携できるので導入が楽
  • ビルド時間が1分ほどかかる

Post Slack messages

github.com

  • slackとはボットユーザトークンで連携
  • ビルド時間が30秒ほど

WorkFlowに追加

今回はSlack Notifyを使いました。
ymlファイルの最後に以下を追加します。

    - name: Slack Notification
      uses: rtCamp/action-slack-notify@master
      env:
        SLACK_CHANNEL: ${{secrets.SLACK_CHANNEL}}
        SLACK_COLOR: '#008000'
        SLACK_TITLE: ':rocket::rocket::rocket: Finished distribute to deploygate! :rocket::rocket::rocket:'
        SLACK_MESSAGE: 'Finished distribute to deploygate! Please check deploygate!'
        SLACK_USERNAME: GitHub Actions
        SLACK_WEBHOOK: ${{secrets.SLACK_WEBHOOK}}

ビルドが失敗したときもslackに通知したい

if: failure()で直前のステップが失敗した場合に実行することができます。

ドキュメント

help.github.com

WorkFlowに追加

ビルドステップのあとに以下を追加します。

    - name: Slack Notification when build failed
      if: failure()
      uses: rtCamp/action-slack-notify@master
      env:
        SLACK_CHANNEL: mobile_app_ci
        SLACK_COLOR: '#ff0000'
        SLACK_TITLE: ':fire::fire::fire: Build error! :fire::fire::fire:'
        SLACK_MESSAGE: "Build error! Please check github!"
        SLACK_USERNAME: GitHub Actions
        SLACK_WEBHOOK: ${{secrets.SLACK_WEBHOOK}}

master以外のブランチに対するpushでworkflowを実行するようにする

branches-ignoreにmasterを設定するとmaster以外のブランチに対するpushでworkflowが実行されます

ドキュメント

help.github.com

WorkFlowに追加

on.pushのあとにbranches-ignoreを追加します。

on: 
  push:
    branches-ignore:
     - 'master'

出来上がったWorkFlow

name: Android CI on Push
on: 
  push:
    branches-ignore:
     - 'master'
jobs:
  build:
    runs-on: ubuntu-latest
    steps:          
    - uses: actions/checkout@v1
    - name: set up JDK 1.8
      uses: actions/setup-java@v1
      with:
        java-version: 1.8
    - name: Build with Gradle
      run: ./gradlew assembleDebug
    - name: Slack Notification when build failed
      if: failure()
      uses: rtCamp/action-slack-notify@master
      env:
        SLACK_CHANNEL: mobile_app_ci
        SLACK_COLOR: '#ff0000'
        SLACK_TITLE: ':fire::fire::fire: Build error! :fire::fire::fire:'
        SLACK_MESSAGE: "Build error! Please check github!"
        SLACK_USERNAME: GitHub Actions
        SLACK_WEBHOOK: ${{secrets.SLACK_WEBHOOK}}
    - name: Distribute App
      run: |
        curl \
         -F "token=${{secrets.DEPLOYGATE_TOKEN}}" \
         -F "file=@app/build/outputs/apk/debug/app-debug.apk" \
         -F "message=https://github.com/${{secrets.GITHUB_USER}}/${{secrets.GITHUB_REPOSITORY}}/commit/`git rev-parse --short $GITHUB_SHA`" \
         -F "distribution_name=$GITHUB_HEAD_REF" \
         https://deploygate.com/api/users/${{secrets.DEPLOYGATE_USER}}/apps
    - name: Slack Notification
      uses: rtCamp/action-slack-notify@master
      env:
        SLACK_CHANNEL: mobile_app_ci
        SLACK_COLOR: '#008000'
        SLACK_TITLE: ':rocket::rocket::rocket: Finished distribute to deploygate! :rocket::rocket::rocket:'
        SLACK_MESSAGE: 'Finished distribute to deploygate! Please check deploygate! https://deploygate.com/users/${{secrets.DEPLOYGATE_USER}}/apps/${{secrets.DEBUG_APPLICATION_ID}}/binaries'
        SLACK_USERNAME: GitHub Actions
        SLACK_WEBHOOK: ${{secrets.SLACK_WEBHOOK}}

We are hiring!

ここまで読んでくれてありがとうございます!
アプリチームでは仲間を募集しています!

actindi.net

とりあえず話を聞いてみたいでも構いません!
五反田でお待ちしております!
それではよいクリスマスを!

Retrofit + Kotlin Coroutines

Android版いこーよを担当しているhondaです。

Android版いこーよでは通信処理でKotlin Coroutines(以下、コルーチン)を使い始めました。
コルーチンは簡単に言うと「スレッドインスタンスのようなものであり、中断可能な計算インスタンス」と言えます。
また、コルーチンは「中断されている最中にスレッドをブロックしない」という特徴を持っています。
なので、通信処理や時間がかかる処理などを非同期で実行でき、かつシンプルなコードで実装できます。

コルーチンについては前回のエントリーでも簡単にですが紹介しました。
tech.actindi.net

いこーよで通信処理にRetrofitを使っています。
Retrofitはバージョン2.5までコルーチンには未対応でした。
よって、バージョン2.5までのRetrofitでコルーチンを使うためには前回のエントリーで書いたようにこちらのサードパーティーのライブラリを使用したり、自前でDeferredを使って対応する必要があります。

最新バージョン2.6でコルーチンに対応したことによって、サードパーティや独自実装なしでコルーチンを使って非同期通信処理を書けるようになりました。

今回はGitHubのSearchAPIに対してのGETリクエストする処理でコルーチンを使わない場合と使った場合でコードの比較をしたいと思います。

GitHubAPIとのインターフェースとなるメソッドの定義

interface GitHubServiceInterface {
    //コルーチン使わないコード
    @GET("search/repositories")
    fun getSearchedRepositories(
        @Query(value = "q") query: String,
        @Query(value = "sort") sort: String,
        @Query(value = "order") order: String
    ): Call<GitHubRepositoriesData>

    // コルーチンを使うコード
    @GET("search/repositories")
    suspend fun getSearchedRepositoriesWithCoroutines(
        @Query(value = "q") query: String,
        @Query(value = "sort") sort: String,
        @Query(value = "order") order: String
    ): GitHubRepositoriesData
}

ここでの違いはsuspendと戻り値です。
コルーチンを使わないコートではリクエストの結果が返ってくるCallオブジェクトを返します
コルーチンを使ったコードではインターフェースとなるメソッドにsuspend修飾子が付き、Callオブジェクトではなく、GETリクエストで取得できるデータのオブジェクトを返します。
suspend修飾子はそのメソッドがコルーチンを中断するかもしれないことを表します。
今回の場合はRetrofit側がGETリクエストを開始してからレスポンスが返ってくるまでコルーチンを中断するため、suspend修飾子を付ける必要があります。

GitHubServiceInterfaceのインスタンスの生成

val gitHubService = Retrofit.Builder()
    .baseUrl("https://api.github.com")
    .build()
    .create(GitHubServiceInterface::class.java)
}

ここはとくにコードの違いはありません。

GETリクエストメソッド

コルーチンを使わないコード

   fun fetchSearchedRepositories(
        query: String,
        sort: String,
        order: String,
        onResponse: (response: GitHubRepositoriesData?) -> Unit,
        onFailure: (throwable: Throwable) -> Unit
    ) {
        gitHubService
            .getSearchedRepositories(query = query, sort = sort, order = order)
            .enqueue(
                object : Callback<GitHubRepositoriesData> {
                    override fun onResponse(
                        call: Call<GitHubRepositoriesData>,
                        response: Response<GitHubRepositoriesData>
                    ) {
                        onResponse(response.body())
                    }

                    override fun onFailure(call: Call<GitHubRepositoriesData>, t: Throwable) {
                        onFailure(t)
                    }
                }
            )
    }

コルーチンを使ったコード

   suspend fun fetchSearchedRepositoriesByCoroutines(
        query: String,
        sort: String,
        order: String
    ): GitHubRepositoriesData {
        return gitHubService.getSearchedRepositoriesWithCoroutines(query = query, sort = sort, order = order)
    }

ここでの違いはコールバックと再び出てきたsuspend修飾子です。
コルーチンを使わないコードではGETリクエストで得られるデータやGETリクエストが失敗時の結果をコールバックで受け取る必要があります
コルーチンを使うコードではまずGETリクエストを行うメソッドはsuspend修飾子が付いています。
これは前述したGitHubEndpointInterface.getSearchedRepositoriesByCoroutines()にsuspend修飾子が付いているため、それを呼び出すfetchSearchedRepositoriesByCoroutines()にもsuspend修飾子を付ける必要があるからです。
GETリクエストで得られるデータはメソッドの戻り値として取得することができます。
コルーチンを使ったコードのほうが圧倒的にシンプルに書けることがわかります。

取得したデータを画面に表示

コルーチンを使わないコード

    fun searchGitHubRepository(query: String) {
        fetchSearchedRepositories(
            query = query,
            sort = "stars",
            order = "desc",
            onResponse = {
                // 検索したGitHubリポジトリを画面に表示する
            },
            onFailure = {
                // リクエスト失敗時の処理を行う
            }
        )
    }

コルーチンを使ったコード

    val coroutineScope = CoroutineScope(context = Dispatchers.Main)
    fun searchGitHubRepositoryByCoroutines(query: String) {
        coroutineScope.launch {
            try {
                val gitHubRepositoriesData = fetchSearchedRepositoriesByCoroutines(
                    query = query,
                    sort = "stars",
                    order = "desc"
                )
                // 検索したGitHubリポジトリを画面に表示する
            } catch (e: HttpException) {
                // リクエスト失敗時の処理を行う
            }
        }
    }

ここでの違いはcoroutineScope.launchです。
CoroutineScope#launchメソッドでコルーチンが生成、実行されます。*1
コルーチンを使っているコードではその生成、実行されたコルーチンの中でfetchSearchedRepositoriesByCoroutinesを呼び出して結果を戻り値として取得することが出来ます。
つまり、非同期通信処理を通常のメソッド呼び出しのように「同期的な」書き方で実装できるため、見通しが良くなることがおわかりになると思います。

まとめ

Retrofitがコルーチンに対応したことで、Retrofitを使った非同期通信処理がとてもシンプルに書けるようになりました。
また、コルーチンに特性により、意図しないUIスレッドのブロックを防ぐことも出来ます。
そして、シンプルに書けることによって、よりアプリとして本質的な機能実装にフォーカスすることができるようになりました。
今後もコルーチンをうまく使って、Android版いこーよアプリをグロースさせていきます!

現在、コルーチンを使ってAndroid版いこーよを一緒にグロースしてくれる仲間を募集しています!
少しでもご興味ある方!以下のリンクからご応募よろしくおねがいします!

actindi.net www.wantedly.com

*1:ちなみにCoroutineScopeを実際のAndroidアプリで使用する際はAndroid Architecture ComponentsでのViewModelなどで生成し、Androidのライフサイクルにリンクするように使用するのが一番多いかと思います。https://developer.android.com/topic/libraries/architecture/coroutines

2019年現在のAndroid版いこーよの開発現場

ブログを見ている皆様、おはこんばんは。
いこーよAndroidアプリを担当しているhondaです。
現在弊社ではAndroidエンジニアを募集しています! www.wantedly.com

今回は「いこーよアプリの応募が気になるけど、何やってるかわからないな」という方向けに現在の仕事をしている環境や体制を簡単などについて書きたいと思います。

開発体制

いこーよアプリは以下のチーム構成で機能追加や改善を行っています。

  • Androidエンジニア:1名
  • iOS兼WebAPI(RoR):1名
  • ディレクター兼デザイナー:1名

通常は自分のロールをこなしていますがチームメンバーが少ないのでロール外のことであっても少しでも気になることは発言して解決していっています。
ディレクター兼デザイナーのメンバーがいこーよアプリ推進の旗振りをしていますが、追加したい機能や施策など思いついたことを毎週月曜に開催しているMTGで発言し、実際にそれをきっかけに実現した機能などもあります。
チームで一番大事にしているのはアプリを使っているユーザであるパパ、ママそして子供たちが楽しくお出かけができることです。
本当に楽しくおでかけができているのかを数値化するのは難しいですが、メンバー全員がパパであり、いこーよアプリのヘビーユーザなのでアプリを使った時に感じる不便さやこんな機能があればいいのにという思いを大事にしています。

開発言語

Android版いこーよは2016年8月から開発がスタートしましたが、当初からKotlinを使っています。

minSdkVersion

現在、23(Android6.0以上をサポート)です。 minSdkVersionはユーザ率が一定数下回ったら、積極的に上げていっています。

targetSdkVersion

現在、27(Android Oreo)です。 現在リニューアルを進めており、そちらは28で開発しています。

設計

方針としてはGuide to app architectureで推奨されている設計をベースにしています。
Guide to app architectureではViewModelがRepositoryを参照していますが、ViewModelが肥大しそうだったのでViewModelからビジネスロジックをUseCaseクラスとして分離したりしています。 リニューアルの開発ではこの設計で進めています。

使っているライブラリ

主に使っているライブラリをいくつかご紹介します。

Okhttp+Retrofit

いこーよアプリの処理でサーバとの通信処理が大部分を締めています。 通信処理でのセッション処理などはretrofitのInterceptorで行っています。 github.com github.com

Glide

スポットの写真やユーザが投稿した口コミの写真の表示に使用しています。 github.com

Permissions Dispatcher

位置情報取得時などで使っています。 github.com

Realm

データの性質によって、通常の保存方法で永続的に取り扱ったり、In Memoryでキャッシュとして保存したりと使い分けをしています。 RealmRecyclerViewAdapterでリストの更新処理が簡単に実装できます。 github.com

Koin

DI(依存性の注入)で使っています。 ViewModelに対応しているのでViewModelの使用がとても楽になりました。 github.com

Androidx

リニューアル開発からandroidxでの開発を進めています。

Kotlin Coroutines

以前は非同期通信でRxJavaを使っていました。
しかし、ほとんどSingleやCompletableを使っていてRxJavaの使い方としてベターではないなと感じていたこととKotlin Coroutines 1.0がリリースされたため、新規機能実装からKotlin Coroutinesを使っています。
既存のRxJavaのコードも順次Kotlin Coroutinesに切り替えていく予定です。

CI

CIはBitriseをDeployGateと連携させて使っています。
GitHubのPRで修正あるたびにビルドして、DeployGateを経由して検証端末で検証できるようにしています。
Android Lint、ktlintも同時に行っていて、Dangerを使ってlint結果をGitHubのPRに表示させています。 www.bitrise.io deploygate.com danger.systems

アナリティクス

Firebase AnalyticsとGoogle Analyticsを併用してログ収集を行っています。
追いかける数値ごとにspreadsheetで集計したり、DataStudioを使って定点観測を行ったりしています。 firebase.google.com analytics.google.com spreadsheets.google.com datastudio.google.com

クラッシュ検知

Firebase Crashlyticsを使っています。
クラッシュを検知した場合、slackに通知するようにしてクラッシュに気づける体制にしています。

firebase.google.com

まとめ

簡単ではありますが、いこーよアプリの開発現場のご紹介でした。
もし、これを見てご興味を持った方がいらっしゃったら幸いです。

現在Android版いこーよをどのように作っていけば迅速に価値を提供するにはどうしたらいいかを模索しながら進めています。
まだまだ、改善すべきところがたくさんあります。
新しいメンバーの方とは一緒に議論しながら今よりもより良いもの作っていきたいと思っています。
一緒に日本中の親子が楽しくおでかけできるサービスを一緒に作っていきましょう。
お待ちしております。

actindiでの3年間のモバイルアプリ開発を振り返る

これはactindi Advent Calendar 2018、1日目の記事です。
Androidアプリエンジニアのhondaです。
実は今日でactindi勤続3年になります。
3年間、いこーよモバイルアプリを担当してきました。
今回は軽く3年間どのようにモバイルアプリ開発をしてきたのか振り返ってみたいと思います。

actindiにジョインした理由

そもそもactindiにジョインした理由ですが、前職で一緒に仕事したことがあり、先にactindiにジョインしていたディレクター兼デザイナーのsuzukiから「一緒にいこーよのモバイルアプリを作らない?」というお誘いを頂いたことがきっかけでした。

いこーよは世の中のパパ、ママに向けて子供とのお出かけ先情報を提供する情報サイトです。  当時のいこーよはモバイルアプリはなく、いこーよからファンのもとへお出かけ先情報を提供していくためにモバイルアプリを作ろうというフェーズでした。
カジュアル面談や面接を受けて、以下の点でジョインを決めました。

  • actindiは当時から個人の自主性を重んじたり、ルールでがんじがらめにするのではなく、自分たちで「一番アウトプットを出す方法は何か?」を考え、実践することを大事にしていました。そこに強く共感することが出来ました。会社のホームページにも掲載されています。
    actindi.net 実際に入社してからもちゃんと文化として定着していて、リモート勤務や何時から何時まで働くか?ということが自分で判断して決めることができるので子持ちの身としてはとても助かりました。
  • いこーよは当時知らなかったのですが、1歳の娘がいたので、サービスにとても魅力を感じた。
  • 最初はiOSアプリを作るがAndroidアプリも作る予定なので今までのAndroid開発経験を活かせる。
  • お給料が上がった。

開発スタート

モバイルアプリ開発は私を誘ってくれたディレクター兼デザイナーと私の2名体制でスタートしました。
前述した通り、いこーよユーザはAndroidよりiOSが多いため、iOS開発から取り掛かりました。
最初の1ヶ月はモバイルアプリの目的を明確にしてペルソナの設定、盛り込む機能/盛り込まない機能の決定、UIの検討&調査に費やしました。
最初のGitHubリポジトリへのコミットは2015年12月28日でした。

技術選定も一任されていました。
方針としては凝ったことをせず、シンプルに作ることを心がけました。
アーキテクチャはMVCライク、データベースはRealm、ネットワーククライアントはAlamofireというような実績のあるものや使いやすいものを採用しました。
また、開発言語はSwiftを採用しました。Objective-CでiOSアプリを作っていたことがあったのでSwiftの書きやすさに感動したものです。
CIはサーバサイドがJenkinsを使っているのでモバイルアプリでもJenkinsを使おうか?とも考えましたが、環境構築に時間をかけたくなかったので、bitriseを使うことにしました。 qiita.com

iOS版開発にWebエンジニアがジョイン

年明けからWebエンジニアでiOSアプリ開発経験もあったnamikataがアプリチームに参画し、チーム3名体制になりました。 チームメンバー全員でのデザインの共有や議論はProttを使って行いました。
実装は私とnamikataで作業を分担して、お互いにコードレビューしながら進めました。
二人ともSwiftはほぼ初心者の状態でしたが、コードレビューで議論しながら開発を進めて行けたことはとても有意義でした。

Swiftに興味のあるWebエンジニアの方からもコードレビューをしていただける機会があったのでとてもありがたかったです。このようなエンジニアがプラットフォームを超えて、プロダクトに対してバリューを出せるのはactindiの強みだと思います。

また、アプリチーム全員が子持ちのお父さんだったこともあり、「親としてどういうアプリだと嬉しいか?」というようなユーザ目線でのサービス開発が出来ました。
actindiに入社する前はチーム全体でユーザ目線で開発するという経験が少なかったので大変でしたがとても楽しかったです。

iOS版いこーよリリース

そして、2016年7月にiOS版いこーよがリリースされました。
2018年11月現在、iOS版のダウンロード数は11万強に到達しました!

f:id:kou_hon:20181128154657p:plain 上の画像はバージョン1.0.0のUIです。
最初のころのUIは自宅や現在位置、お出かけ先でのスポットを直感的に見つけられるようにマップをメイン画面にしました。
また、行きたいスポットを見つけたら保存しやすくするためボタンを大きめにしたり、行ったスポットの口コミを書きやすくるために入力エリアをわかりやすくしたり、書きやすくするような配慮をUI設計で行いました。

Android版開発スタート

iOSがリリースした後、2016年8月からAndroid版の開発がスタートしました。
基本的にはiOSで実装したUIや機能をAndroid版に移植するような流れでした。
が、Android版ではiOS版をそのまま移植してしまうとAndroidアプリらしさがないものになってしまいます。
Androidユーザの混乱を少しでも軽減するためMaterial Designに則ったUIにするようにしました。 アーキテクチャはiOS版の開発で出てきた課題を鑑みつつ、最初はMVC+レイヤードアーキテクチャーのようなものにしました。
データベースはiOSで使い慣れたRealm、ネットワーククライアントはRetrofitを採用しました。 Android版でもシンプルな設計を目指しました。

言語は私の提案でKotlinにしました。Kotlinはいいぞ。 tech.actindi.net

CIはAndroid版でもbitriseにしました。 現在、iOS版とともにbitriseでプルリクごとにビルドして、deploygateで社内配信する構成になっています。

息子誕生

私事ですが、Android版開発中に息子が誕生しました。
この頃から自宅でのリモート勤務が多くなってきましたが、前述した通りチームメンバーがみんな子持ちなのでメンバーがお互いを考えながらフォローしあえました。
actindiはコアタイムの無いフレックス制を導入しています。子供の風邪などの急な対応がしやすくとてもありがたかったです。
こういう時、「周りに迷惑をかけて申し訳ない・・・」と思いがちですが全社的に「お互い様」の考え方が定着しているので、安心してリモート勤務や勤務時間を変更したりすることができました。
アウトプットも極端に低くなったということはなく、チームに貢献することができたと思います。

Android版いこーよリリース

2017年2月にAndroid版いこーよがリリースされました。
2018年11月現在、Android版もダウンロード数が11万強に到達しました!

f:id:kou_hon:20181130135556p:plain:w320

Android版はiOS版よりシンプルなUIになっていますが、スポットの検索性や口コミのユーザ体験はiOSと同じになるように実装しました。

アプリの成長

Android版リリース後、iOS版、Android版ともにFirebaseAnalytics、GoogleAnalyticsを使ったユーザ行動解析、アプリ内でのユーザアンケート、などなどいろいろな角度からいこーよユーザにとっての使いやすいアプリを目指し改修を進めていきました。
毎週月曜日は定例ミーティングを開き、上記のデータやKPI、KGIをチーム全体で確認しあい、アプリとしてどういった成長が必要なのかを話し合っていきました。
そのミーティングでエンジニア、ディレクターの垣根を超えて、施策の提案なども行い、チームメンバー全員が施策に対してポジティブな意見で肉付けをしていき、具体的にサービスづくりに落とし込んでいきました。
小さいチームならではの動き方ではあると思いますがactindiらしい良いチームの動きだなと感じています。

UIの変化

下の画像は2018年11月現在のAndroid版、iOS版のメイン画面です。リリース当時に比べてだいぶ変わりました。
Android版はマップベースですが、iOS版はさらに普段遣いができるアプリを目指すため、週末に子供とのお出かけ先をすぐに見つけられるように季節、イベント、口コミ、クーポンなど様々な角度からおすすめのスポット情報を提供できるUIにしました。

f:id:kou_hon:20181130135235p:plain:w290 f:id:kou_hon:20181130134850p:plain:w290

アーキテクチャー

アプリのアーキテクチャーも最初のころとは変化していきました。
namikataがブログでiOS版の実装について触れていますのでぜひ御覧ください。 tech.actindi.net tech.actindi.net tech.actindi.net

Android版はMVCからAndroid Architecture Componentsをベースにしたものに設計方針を変え、よりテスタブルで追加実装しやすい設計に変更しています。詳しいことは別のエントリでお伝えできればと思います。

不具合対応

運用初期のころは毎朝、Google Play ConsoleやFirebaseをチェックしていち早く不具合に対応するように心がけていました。
今ではクラッシュ通知をslackに通知するようにして、より早く不具合に対応できる体制にしています。段階的リリースの利用やリリースタイミングなどを臨機応変に変えていきながら、安全にサービスを提供できるように心がけています。

評価

これまでお伝えしたアプリの成長によって、いこーよユーザの方々からAndroid版、iOS版ともに4.4の評価をいただきました。ありがとうございます!

f:id:kou_hon:20181130135858p:plain:w290 f:id:kou_hon:20181130135948p:plain:w290

これからのモバイル版いこーよアプリ

上記の現状のiOS版のUIでいい結果を残せたのでそのUIをベースにした新UIを目下開発中です。
iOS版のUIでの反省点や改善点を踏まえつつ、Androidらしい新UIを目指していきます。
iOS版、Android版ともにこれからもいこーよユーザにとって使いやすいアプリを目指すために成長させていきます。ご期待下さい!

まとめ

actindiでの3年間のモバイルアプリ開発の中でチームメンバー同士助け合い、より良いアプリを作るために議論し、いこーよモバイルアプリを成長させてきました。
そんないこーよアプリチームではモバイルエンジニアを募集しています!

actindi.net

いこーよアプリチームに興味がある方、いこーよアプリに興味がある方、いこーよアプリチームメンバーと話してみたい方などなど、ご連絡お待ちしています!

KOINのバージョンが1.0になりました。

すっかり秋めいて来ましたね。
いこーよのAndroidアプリを担当しているhondaです。
前回、KOINの導入をお話をしました。 tech.actindi.net この時に使ったKOINのバージョンは0.9.3でした。
今回、KOINのバージョンが1.0にアップデートされたので(2018/10/26現在の最新バージョンは1.0.1)前回のサンプルコードでのKOIN0.9.3→1.0への以降をしながらKOIN1.0のお話をしたいと思います。

バージョンを変更

build.gradle(app)のKOINバージョンを0.9.3から1.0.1に変更します。
gradle syncすると以下のエラーが表示されます。

f:id:kou_hon:20181026130023p:plain

org.koin:koin-android-architectureがなくなって、Android Architecture ComponentsのViewModelを使用するためには「org.koin:koin-android-viewmodel」を導入する必要があります。
よって、前回の

def koin_version = '0.9.3'
// Koin for Kotlin
implementation "org.koin:koin-core:$koin_version"
// Koin for Android
implementation "org.koin:koin-android:$koin_version"
// Koin for Android Architecture Components
implementation "org.koin:koin-android-architecture:$koin_version"
// Koin for JUnit tests
testImplementation "org.koin:koin-test:$koin_version"


def koin_version = '1.0.1'
// Koin for Kotlin
implementation "org.koin:koin-core:$koin_version"
// Koin for Android
implementation "org.koin:koin-android:$koin_version"
// Koin Android ViewModel feature
implementation "org.koin:koin-android-viewmodel:$koin_version"
// Koin for JUnit tests
testImplementation "org.koin:koin-test:$koin_version"

となり、gradle syncが成功します。

ビルド

gradle syncが完了したのでビルドしてみます。
すると、ビルドエラーが発生します。

f:id:kou_hon:20181026130804p:plain

これはViewModelのinjectionを行うviewModelのパッケージが変更されたためです。

import org.koin.android.architecture.ext.viewModel

から

import org.koin.android.viewmodel.ext.android.viewModel

に変更します。

Deprecated対応

ビルドは通るようになって動くようにはなりました。
が、いくつかのメソッドがDeprecatedになっています。
Deprecatedの対応をしていきましょう。
カスタムApplicationクラスのコードを見るとこうなっています。

f:id:kou_hon:20181026131158p:plain

applicationContext関数はmodule関数に変更、beanメソッドはsingleメソッドに変更になり、よりわかりやすい命名になりました。

val myModule: Module = applicationContext {
    viewModel { MainViewModel(get()) }
    factory { SampleNumCounter(get()) as SampleNumCounterInterface }
    bean { SampleNumDataSource() as SampleNumDataSourceInterface }
}

から

val myModule: Module = module {
    viewModel { MainViewModel(get()) }
    factory { SampleNumCounter(get()) as SampleNumCounterInterface }
    single { SampleNumDataSource() as SampleNumDataSourceInterface }
}

に変更します。
以上で前回のサンプルコードを使ったKOIN0.9.3→1.0への変更対応になります。

AndroidX対応

サンプルコードではandroid.arch.lifecycleのViewModelを使っています。
androidx.lifecycleのViewModelを使う場合はorg.koin:koin-android-viewmodel
ではなくAndroidXに対応した
org.koin:koin-androidx-viewmodel
をbuild.gradleに書いておきます。
これでAndroidXでも安心です。

追加されたパッケージ

KOIN1.0から
koin-android-scope
というパッケージが追加されました。
これはActivity、Fragment、Serviceにインジェクトするオブジェクトをインジェクト先のライフサイクルに合わせて管理するためのパッケージのようです。
下記のコードは公式サイトより抜粋したものです。

基本的な使い方

公式ではMVPアーキテクチャーでのPresenterを例にあげています。

まず、Presenterクラスをmoduleに追加します。
scopeで"scope_id"というIDで追加追加します。

val androidModule = module {
    scope("scope_id") { Presenter() }
}

次にSampleActivityにPresenterクラスをインジェクトします。
getScopeでPresenterのインスタンスが新規で生成され、bindScopeでMyActivityにbindされます
これでPresenterインスタンスはMyActivityのライフサイクルが終了すると破棄されるようになります。

class MyActivity : AppCompatActivity() {

    val presenter : Presenter by inject()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        bindScope(getScope("scope_id"))
    }

※注意:getScopeだとインスタンスがないと例外を吐いてしまうのでgetOrCreateScopeを使ったほうが良いようです。

インスタンスをシェアする使い方

また、koin-android-scopeの用途としてActivity間などでオブジェクトをシェアする使い方も公式より紹介されています。
例としてユーザのセッション情報を保持しているUserSessionクラスをシェアするパターンがあります。
まずModuleにUserSessionクラスを追加します。

module {
    scope("session") { UserSession() }
}

次にユーザセッションを使用したいタイミングで以下のコードでスコープを開始します。

getKoin().createScope("session")

そして、以下のようにUserSessionを使用したいActivityなどでinjectします。
これでMyActivity1とMyActivity2でUserSessionのインスタンスをシェアすることが出来ます。

class MyActivity1 : AppCompatActivity() {
    val userSession : UserSession by inject()
}
class MyActivity2 : AppCompatActivity() {
    val userSession : UserSession by inject()
}

また、Kotlin DSLに沿ってシェアすることも出来ます。 例えば、前述したPresenterでUserSessionを使いたい場合、以下のようにModule定義します。

module {
    scope("session") { UserSession() }
    factory { Presenter(get())}
}

次に以下のようにPresenterクラスの生成時にuserSessionを渡すように書き直します。
これでシェアされているUserSessionのインスタンスがPresenterにinjectされます。

class Presenter(val userSession : UserSession)

最後にユーザセッションを終了させたいためなどにUserSessionのインスタンスを破棄する場合は以下のようにclose処理を呼び出します。

val session = getKoin().getScope("session")
session.close()

まとめ

いかがでしたでしょうか?
バージョン1.0からよりAndroidフレンドリーな使い方が出来るようになりました。
KOINはいいぞ。

最後に

We're Hiring Android Engineers! actindi.net

VSCodeでFlutter、最初の一歩

Web エンジニアの morishitaです。

Web アプリは Android も iOS を1つのソースで動かせるのに、 それぞれ作らないといけないなんてネイティブアプリ開発は大変だなぁといつも横目でちら見しております1

プッシュ通知やオフラインキャッシュなどアプリ開発の強い動機になっていた機能が Webアプリでも実装可能になってきており、AndroidではPWAアプリをホームスクリーンに追加すると 単なるショートカットではなくネイティブアプリ同様に扱われるようになってきています2

それでも、端末の機能をほぼ制限なく使え、カメラやセンサやNFCなどを利用した3ネットとリアルのよりリアル寄りのアプリケーションを開発できるネイティブアプリの自由さはいいなぁと思うことがあります。

Webアプリのようにネイティブアプリを開発できる、AndroidもiOSもワンコードですと謳う開発環境やフレームワークはこれまでもありましたが、スマホの2大プラットフォーマーの1つGoogleを中心に開発が進められているものが出てきました。それがFlutterです。

真打ち登場かもしれません。ということで、ちょっと触ってみました。

Flutterとは

Google が公開しているスマホ向けネイティブアプリの開発プラットフォームと言うかフレームワークで OSS として開発されています。

flutter.io

次の特徴を謳っています。

  • Fast development
    • ホットリロードでコードの変更がすぐに反映され、試しながら実装できる
    • カスタマイズ可能なUI部品
    • 1つのコードベースでAndroid、iOS のクロスプラットフォーム開発できる
  • Expressive and Flexible UI
    • マテリアルデザインとクパティーノ (iOS-flavor)のUIパーツ
    • 各プラットフォームに配慮したリッチな動きやスムーズなスクロール
  • Native Performance
    • iOSとAndroidの両方で完全なネイティブパフォーマンスを提供

また、実装言語にはDartを採用ししています。 サードパーティのパッケージを Flutter Packagesで探して利用することもできます。

このエントリではVSCodeでFlutterによるアプリ開発するためのセットアップからサンプルアプリの動作確認までやってみます。

セットアップ

以降はMac OS 上でのセットアップの流れです。

Android Studio と Xcode のインストール

あれ?VSCode で Flutter じゃないの? と思われるかもしれないですが、 最終的には Android /iOS 向けにビルドする必要があり、それぞれビルドするためのツールチェインが必要になります。そのためにインストールします。

まずはAndroidから。Android Studio をインストールしましょう。 Android に詳しい人は SDK だけでもいいかもしれないですが、そういう人はすでに Android Studio もインストールしているでしょう。多分。 詳しくない人は Android Studio をインストールしてしまうのが手っ取り早いし簡単です。

Android Studio のインストール  |  Android デベロッパー  |  Android Developers

つづいて、iOS。 Xcode は App Store からインストールしましょう。

Xcode

Xcode

  • Apple
  • 開発ツール
  • 無料

Flutter SDK のインストール

お待ちかね、Flutterのインストールです。
インストールと言っても次のページから ZIP ファイルをダウンロードして展開するだけです。

flutter.io

展開したら、flutter というディレクトリができます。それを任意の場所に配置します。 私は ~/Libraryの下に置いています。

置き場所を決めたら次の様に flutter/bin にパスを通します。

export PATH=$HOME/Library/flutter/bin:$PATH

Flutter Doctorの実行

これで、flutter コマンドが使えるようになっているはずです。

ターミナルを開いて環境チェックコマンド flutter doctorを実行しましょう。

私の環境では次の様な結果が出力されました。

$ flutter doctor

  ╔════════════════════════════════════════════════════════════════════════════╗
  ║                 Welcome to Flutter! - https://flutter.io                   ║
  ║                                                                            ║
  ║ The Flutter tool anonymously reports feature usage statistics and crash    ║
  ║ reports to Google in order to help Google contribute improvements to       ║
  ║ Flutter over time.                                                         ║
  ║                                                                            ║
  ║ Read about data we send with crash reports:                                ║
  ║ https://github.com/flutter/flutter/wiki/Flutter-CLI-crash-reporting        ║
  ║                                                                            ║
  ║ See Google's privacy policy:                                               ║
  ║ https://www.google.com/intl/en/policies/privacy/                           ║
  ║                                                                            ║
  ║ Use "flutter config --no-analytics" to disable analytics and crash         ║
  ║ reporting.                                                                 ║
  ╚════════════════════════════════════════════════════════════════════════════╝

Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel beta, v0.7.3, on Mac OS X 10.12.6 16G1510, locale ja-JP)
[!] Android toolchain - develop for Android devices (Android SDK 28.0.1)
    ! Some Android licenses not accepted.  To resolve this, run: flutter doctor --android-licenses
[!] iOS toolchain - develop for iOS devices (Xcode 9.2)
    ✗ libimobiledevice and ideviceinstaller are not installed. To install, run:
        brew install --HEAD libimobiledevice
        brew install ideviceinstaller
    ✗ ios-deploy not installed. To install:
        brew install ios-deploy
    ✗ CocoaPods not installed.
        CocoaPods is used to retrieve the iOS platform side's plugin code that responds to your plugin usage on the Dart side.
        Without resolving iOS dependencies with CocoaPods, plugins will not work on iOS.
        For more info, see https://flutter.io/platform-plugins
      To install:
        brew install cocoapods
        pod setup
[✓] Android Studio (version 3.1)
[!] IntelliJ IDEA Ultimate Edition (version 2018.2.1)
    ✗ Flutter plugin not installed; this adds Flutter specific functionality.
    ✗ Dart plugin not installed; this adds Dart specific functionality.
[!] VS Code (version 1.27.2)
[!] Connected devices
    ! No devices available

! Doctor found issues in 5 categories.

PC 内の必要なモジュールがチェックされます。 [!]がついた項目が問題のある項目です。

これらを解消していきます。

Android 環境と iOS 環境の準備

上記の問題箇所に対処して、Android 環境と iOS 環境を整えます。

Android 環境

flutter doctorの結果の次の部分ですが、これは何やらライセンスに同意していないと注意されています。

[!] Android toolchain - develop for Android devices (Android SDK 28.0.1)
    ! Some Android licenses not accepted.  To resolve this, run: flutter doctor --android-licenses

指示に従って、flutter doctor --android-licensesを実行して同意すれば解決です。

次の項目ですが、IntelliJ についてはプラグインが足りないと言っているので、Flutter pluginDart pluginをインストールします。

[✓] Android Studio (version 3.1)
[!] IntelliJ IDEA Ultimate Edition (version 2018.2.1)
    ✗ Flutter plugin not installed; this adds Flutter specific functionality.
    ✗ Dart plugin not installed; this adds Dart specific functionality.

上記では Android Studio には問題ないですが、予めこれらのプラグインをインストールしていたためです。
インストールしていなけれは IntelliJ と同様のメッセージが出るので、それに従います。

iOS 環境

iOS toolchain についても解決方法が示されています。 それぞれ解決するための brewのパッケージインストールコマンドが示されているので、順に実行します。 上記のflutter doctorの結果の場合、次のコマンドを実行します。

$ brew install --HEAD libimobiledevice
$ brew install ideviceinstaller
$ brew install ios-deploy
$ brew install cocoapods
$ pod setup

brew install --HEAD libimobiledeviceが python2 に依存するらしく、python2 が無い環境だと自動的にpython2がインストールされます。 しかし、python2 にはpipが含まれないので、途中でコケます。
その場合、Installation — pip 18.0 documentationを参考にインストールして再実行すれば解決します。

また、cocoapodsのインストールには結構時間がかかるのでご注意を。

Flutter Doctorが解決方法も示してくれるのでそれに従っていけば割とスルッとできると思います。

VSCode と Flutter プラグインのインストールと設定

さて、いよいよ VSCode です。 Flutter Doctorの出力で次のように指摘されています。 特に解決方法が示されていませんが、単に Flutter プラグインがインストールされていないだけです。

[!] VS Code (version 1.27.2)

なので、次のプラグインをインストールしましょう。

marketplace.visualstudio.com

marketplace.visualstudio.com

インストールしたら、コマンドパレットからFlutter: New Projectを実行してみてください。

初めてだと次のようなダイアログが表示されると思います。
もし、プロジェクト名を聞いてきたら、飛ばして次に進んでいいです。

f:id:HeRo:20180920083921p:plain

Flutter SDK の場所がわからないと言っているだけなので、Locate SDKボタンをクリックして設定します4

設定したらコマンドパレットからFlutter: Run Flutter Doctorを実行します。
次のように結果がターミナルに出力されれれば OK です。

$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel beta, v0.8.2, on Mac OS X 10.12.6 16G1510, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK 28.0.1)
[✓] iOS toolchain - develop for iOS devices (Xcode 9.2)
[✓] Android Studio (version 3.1)
[✓] IntelliJ IDEA Ultimate Edition (version 2018.2.1)
[✓] VS Code (version 1.27.2)
[!] Connected devices
    ! No devices available

Connected devicesは気にしなくていいです。
Flutterプラグインから接続できる端末がないと行っているだけです。エミュレータを起動すれば解決します。

Flutter Doctorの結果がきれいになったところでセットアップは終了です。

サンプルプロジェクトで動作確認

さて、セットアップが終わったところで、サンプルを実行してみましょう。

プロジェクトの初期化

再び Flutter: New Project を実行します。 今度はプロジェクト名を聞いてくるので、適当に入力して保存する場所を選択したら、ファイル群が生成されプロジェクトが初期化されます。

この時点で、簡単なアプリのコードが含まれています。

仮想デバイスの準備

VSCodeのステータスバーに No Devices と表示されているのでクリックするとコマンドパレットに選択肢が表示されます。多分、iOS Simulator は最初から選択できるのではと思います。選択するとエミュレータが起動します。

f:id:HeRo:20180920235957p:plain

Android のエミュレータはCreate Newを選択すると自動的に作成されて起動します。

なお、作成するにはエミュレータのシステムイメージが必要です。 SDK マネージャを開いて、Android8.1Google play Intel x86 Atom System Imageをインストールすると、作れるようになります。

f:id:HeRo:20180920084025p:plain

Create Newを実行すると次の仮想デバイスが作成されます。

f:id:HeRo:20180920084038p:plain

実行

エミュレータの準備ができたらサンプルアプリを動かしてみましょう。

VSCode のデバッグを開くと Flutter を選択できるようになっています。 選択して、実行するとプロジェクトがビルドされエミュレータで実行されます。

f:id:HeRo:20180920235031p:plain

サンプルアプリは右下のボタンをクリックすると数字がカウントアップするだけのシンプルなものです。

試しに、lib/main.dartを開いてFlutter Demo Home Pageの文字列を書き換えたり、UIの色を変更ししてみると、すぐにエミュレータで実行されているアプリに反映されます。
これが Flutter の特徴のホットリロードですね。素晴らしい。

f:id:HeRo:20180921000823g:plain

以上でサンプルアプリの動作確認も終了です。

おまけ

セットアップしてからこのエントリを書くまでの間に Flutter のバージョンが0.7.2から0.8.3上がってしまいました。 ちょうどいいのでアップデートも試してみました。

FLutter のバージョンアップは flutter upgradeコマンドで可能です。

flutter upgradeの実行の様子はちょっと長いので折りたたみます。 興味ある方は開いて見てください。

で、アップデート後、flutter --versionでバージョンを確認します。

$ flutter --version
Flutter 0.8.2 • channel beta • https://github.com/flutter/flutter.git
Framework • revision 5ab9e70727 (12 days ago) • 2018-09-07 12:33:05 -0700
Engine • revision 58a1894a1c
Tools • Dart 2.1.0-dev.3.1.flutter-760a9690c2

ちゃんと上がっていますね。 アップデート後、VSCodeからアプリを起動してみましたが問題ありません。 アップデートも簡単です。

まとめ

難しいハマりどころはなく、VSCodeでFlutter環境は作れます。アップデートも難しくありませんでした。

結局 Android Studioをインストールするので(しかも私は Intellijも使っていてAndroid Studioにも抵抗ないので) VSCodeを使うのは微妙な気もしないではないですが、選択肢があるのは良いことではないでしょうか。

Flutter自体がまだRelease Preview 2が出たところなので実戦投入には十分な検証が必要だと思うのですが、プロトタイピングしてみる程度なら十分使えそうです。 特にホットリロードは小気味よく、Web開発感覚でアプリ開発できそうです。

最後に

アクトインディではWebでもアプリでも、インフラでもいろいろやりたいエンジニアを募集しています。


  1. 両プラットフォームへの配慮は必要ですし、時には作り分けたりする必要もありますが。

  2. プッシュ通知欲しさに開発されたほぼWeb版と変わらない(しかも全面的にUIはwebviewだったりするような)ネイティブアプリはAndroidに於いては減ってくるだろうなぁと個人的に思っています。

  3. ブラウザでもだいぶサポートされてきていますが、安定性やパフォーマンス含め広く一般的に使える状況まではもう少し掛かりそうですね。

  4. ~/Libraryに SDK を置いている場合、ファイル選択ダイアログでこのディレクトリが表示されなくて選択できないかもしれません。ターミナルを開いてchflags nohidden ~/Libraryを実行すると表示されるようになります。

koin始めました。

f:id:kou_hon:20180821105751j:plain 暑中お見舞い申し上げます。
ビールが美味しい季節ですね。

いこーよのAndroidアプリを担当しているhondaです。
この夏、koinを始めました。

koinとは?

Kotlinのために作られたDI(依存性の注入)を実現するための軽量フレームワークです。 github.com

DI(依存性の注入)とは?

以下のようなクラスがあったとします。

public SampleNumCounter {
    private val sampleNumDataSource: SampleNumDataSource
[f:id:kou_hon:20180821105751j:plain]
    init {
        this.sampleNumDataSource = SampleNumDataSource()
    }

    fun countSampleNum() {
        this.sampleNumDataSource.sampleNum++
    }
}

SampleNumCounterはSampleNumDataSourceに依存しています。
SampleNumCounterにDI(依存性の注入)を適用すると下記のようになります。

public SampleNumCounter(private val sampleNumDataSource: SampleNumDataSource) {
    fun countSampleNum() {
        this.sampleNumDataSource.sampleNum++
    }
}

SampleNumCounterのコンストラクタで依存するSampleNumDataSourceを注入するようにしました。
これでSampleNumDataSourceをモックすることができ、SampleNumCounterがTestableになりました。

サンプルアプリを用意しました。

ボタンをクリックする事に数字をインクリメントして画面に表示するアプリを作りました。
まず画面となるActivityクラスです。

class MainActivity : AppCompatActivity() {
    private lateinit var mainViewModel: MainViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        this.mainViewModel = ViewModelProviders
            .of(
                this,
                MainViewModelFactory(
                    sampleNumCounter = SampleNumCounter(
                        sampleNumDataSource = SampleNumDataSource()
                    )
                )
            )
            .get(MainViewModel::class.java)

        this.mainViewModel.sampleNumStringLiveData.observe(this, Observer {
            this.activityMainSampleTextView.text = it
        })

        this.activityMainSampleButton.setOnClickListener {
            this.mainViewModel.countSampleNum()
        }
    }
}

MainViewModelにパラメータを渡すために必要になるFactoryクラスです。

class MainViewModelFactory(
    private val sampleNumCounter: SampleNumCounterInterface
) : ViewModelProvider.Factory {

    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        return MainViewModel(sampleNumCounter = this.sampleNumCounter) as T
    }
}

ActivityクラスとSampleNumCounterクラスとの仲立ちをするViewModelクラスです。

class MainViewModel(val sampleNumCounter: SampleNumCounterInterface) : ViewModel() {
    val sampleNumStringLiveData = MutableLiveData<String>()

    fun countSampleNum() {
        this.sampleNumCounter.countSampleNum()
        this.sampleNumStringLiveData.value = this.sampleNumCounter.getSampleNumString()
    }
}

数字のインクリメントと出力を担当するSampleNumCounterクラスとそのinterfaceです。

interface SampleNumCounterInterface {
    fun countSampleNum()
    fun getSampleNumString(): String
}
class SampleNumCounter(
    private val sampleNumDataSource: SampleNumDataSourceInterface
) : SampleNumCounterInterface {
    override fun countSampleNum() {
        this.sampleNumDataSource.sampleNum++
    }

    override fun getSampleNumString(): String {
        return "SampleNum is ${this.sampleNumDataSource.sampleNum}"
    }
}

数字を保持するDataStoreクラスとそのinterfaceです。

interface SampleNumDataSourceInterface {
    var sampleNum: Int
}
class SampleNumDataSource: SampleNumDataSourceInterface {
    override var sampleNum: Int = 0
}

koinを始めてみましょう

以下の設定でkoinを使えるようになります。 build.gradle(project)にjcenter()が記述されているかを確認します。

buildscript {
    repositories {
        jcenter()
    }
}

build.gradle(module)のdependenciesに以下を追加します
※下記に記述されているkoin_versionは2018/08/06付現在の最新バージョンです。

def koin_version = '0.9.3'
// Koin for Kotlin
implementation "org.koin:koin-core:$koin_version"
// Koin for Android
implementation "org.koin:koin-android:$koin_version"
// Koin for Android Architecture Components
implementation "org.koin:koin-android-architecture:$koin_version"
// Koin for JUnit tests
testImplementation "org.koin:koin-test:$koin_version"

koinを使ってみましょう

まずkoinは依存定義をApplicationクラスを継承したクラス内で行います。

class KoinSamleApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        // startKoin()でkoinを経由したDIが使用できるようになります。
        startKoin(this, listOf(myModule))
    }
}
// applicationContext関数を使って依存関係を定義します。
// factoryで依存定義されたSampleNumCounterは呼び出される度に新しいインスタントが生成されます。
// get()でSampleNumCounterが依存するクラスのインスタンスが挿入されます。
// beanで依存定義されたSampleNumDataSourceはシングルトンとして生成されます。
val myModule: Module = applicationContext {
    factory { SampleNumCounter(get()) }
    bean { SampleNumDataSource() as SampleNumDataSourceInterface }
}

SampleNumCounterクラスに依存しているMainViewModelFactoryクラスを書き直します。

class MainViewModelFactory : ViewModelProvider.Factory, KoinComponent {

    // 下記でkoinが変数sampleNumCounterにSampleNumCounterのインスタンスをインジェクトしてくれます
    // Activity以外でinjectを使う場合はKoinComponentを適用すると使用できます。
    private val sampleNumCounter by inject<SampleNumCounter>()

    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        return MainViewModel(sampleNumCounter = this.sampleNumCounter) as T
    }
}

koinはViewModelにも対応しています。 まずKoinSamleApplicationクラスの依存定義を書き直します。

class KoinSamleApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        startKoin(this, listOf(myModule))
    }
}

val myModule: Module = applicationContext {
    // viewModelでMainViewModelの依存定義を追加します。
    viewModel { MainViewModel(get()) }
    factory { SampleNumCounter(get()) as SampleNumCounterInterface }
    bean { SampleNumDataSource() as SampleNumDataSourceInterface }
}

次にMainActivityを書き直します。

class MainActivity : AppCompatActivity() {
    // 下記で変数mainViewModelにMainViewModelのインスタンスがインジェクトされます。
    // これによりonCreateで記述してたViewModelProvidersの処理とMainViewModelFactoryクラスが必要なくなります。
    private val mainViewModel by viewModel<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        this.mainViewModel.sampleNumStringLiveData.observe(this, Observer {
            this.activityMainSampleTextView.text = it
        })

        this.activityMainSampleButton.setOnClickListener {
            this.mainViewModel.countSampleNum()
        }
    }
}

まとめ

いかがでしたでしょうか?
koinを使うことによって、DI定義がすっきりしました。
Android Architecture ComponentのViewModelの取り扱いも楽になりました。
koinはKotlinで書かれているのでKotlinとの相性も抜群です。

今回使用したAndroid Architecture ComponentのViewModelはandroid.arch.lifecycleのものです。
androidxは現時点では対応していないようです。
ですが、バージョン1.0.0のブランチを見るとAndroidXに対応する記述があるので一安心です。
github.com

最後に

We're Hiring Android Engineers! actindi.net

Kotlin Android Extensionsを使ってみよーよ

この記事はactindi Advent Calendar 2017の15日目の記事です。

Androidエンジニアのhondaです。去年のアドベントカレンダーではKotlinのことを書きましたが今年もKotlinについて書きます。よろしくお願いします。

現在、Android版いこーよではKotlinを使っています。100%Kotlinです。 弊社でKotlinアプリを作ってみたい方はこちらとかこちらなどで応募お願いします。お願いします!

前置きはこのくらいにして、Android版いこーよではKotlin Android Extensionsを使っています。 なかなか、プロダクトでのKotlin Android Extensionsの採用を聞かないので、今回はKotlin Android Extensionsの素晴らしさをお伝えしたいと思います。

Kotlin Android Extensionsとはなにか?

Kotlin Android ExtensionsはKotlinの開発元であるjetbrains社が開発したAndroid開発を支援するためのプラグインです。 このプラグインを使うことによって、Androidアプリエンジニアの方ならおなじみの「findViewById」を使わずにコードからUIの要素を参照することが出来るようになります。

Kotlin Android Extensionsを導入してみよう!

Android Studio 3.0以上で新規プロジェクトを作成するとデフォルトで Kotlin Android Extensions使えるようになっています。素晴らしいですね。 既存プロジェクトの場合はbuild.gradle (Module: app)に以下を追加するだけです。

apply plugin: 'kotlin-android-extensions'

簡単ですね。

Kotlin Android Extensionsでコードを書いてみよう!

・Activity

簡単なアプリを作ってみましょう。 TextViewとButtonが配置された画面でTextViewには”Hello World!”と表示されています。 ButtonをタップするとTextViewの”Hello World!”の表示が”ハローワールド!”に切り替わります。 コードは以下の通りです。

Activityのレイアウトファイル
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="sample.samplekotlinandroidextensions.MainActivity">

    <TextView android:id="@+id/textView" android:layout_width="wrap_content" android:layout_height="wrap_content"
              android:text="Hello World!"
              app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent"
              app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" />
    <Button android:id="@+id/button" android:layout_width="wrap_content"
            android:layout_height="wrap_content" android:layout_marginTop="8dp"
            android:text="ハローワールド!" app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textView" />

</android.support.constraint.ConstraintLayout>
Activityのソース
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        this.button.setOnClickListener {
            this.textView.text = "ハローワールド!"
        }
    }
}

これでfindViewByIdを使わずにtextViewに対して文言を設定することが出来ます。 レイアウトファイルでIDに設定した名前でコード上から参照出来ます。

・Fragment

次にFragmentで試してみましょう。 先程のActivityのSample同様にButtonをタップするとTextViewの内容が変わるものを作成します。

Fragmentのレイアウトファイル
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
                                             xmlns:app="http://schemas.android.com/apk/res-auto"
                                             xmlns:tools="http://schemas.android.com/tools"
                                             android:id="@+id/frameLayout"
                                             android:layout_width="match_parent" android:layout_height="match_parent"
                                             tools:context="sample.samplekotlinandroidextensions.SampleFragment">

    <TextView android:id="@+id/helloFragmentTextView" android:layout_width="wrap_content"
              android:layout_height="wrap_content" android:text="@string/hello_fragment"
              app:layout_constraintBottom_toBottomOf="parent"
              app:layout_constraintEnd_toEndOf="parent"
              app:layout_constraintStart_toStartOf="parent"
              app:layout_constraintTop_toTopOf="parent" />
    <Button android:id="@+id/helloFragmentButton" android:layout_width="wrap_content"
            android:layout_height="wrap_content" android:layout_marginEnd="8dp"
            android:layout_marginStart="8dp" android:layout_marginTop="8dp"
            android:text="@string/hello_fragment" app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/helloFragmentTextView" />

</android.support.constraint.ConstraintLayout>
Fragmentのソース
class SampleFragment : Fragment() {

    override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?): View? {
        val view = inflater.inflate(R.layout.fragment_sample, container, false)
        view.helloFragmentButton.setOnClickListener {
            view.helloFragmentTextView.text = "ハロー フラグメント"
        }
        return view
    }
}

ここで注意しなければならないのがonCreateViewでのUIの要素の参照方法です。 上記のサンプルの用にonCreateViewではinflateしたviewに対してUIの参照を行わないといけません。

override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?): View? {
    val view = inflater.inflate(R.layout.fragment_sample, container, false)
    this.helloFragmentButton.setOnClickListener {
        this.helloFragmentTextView.text = "ハロー フラグメント"
    }
    return view
}

上記の様にUIに参照しようとするとNullPointerExceptionが発生します。 onCreateView以外では下記の様にUIに参照出来ます。

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    this.helloFragmentButton.setOnClickListener {
        this.helloFragmentTextView.text = "ハロー フラグメント"
    }
}

・RecyclerView

次にRecyclerViewで試してみましょう

RecyclerViewに表示するItemのレイアウトファイル
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView android:id="@+id/sampleRecyclerItemTextView" android:layout_width="wrap_content"
              android:layout_height="wrap_content" android:layout_marginBottom="8dp"
              android:layout_marginEnd="8dp" android:layout_marginStart="8dp"
              android:layout_marginTop="8dp"
              tools:text="TextView"
              app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent"
              app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toStartOf="parent"
              app:layout_constraintTop_toTopOf="parent" />
</android.support.constraint.ConstraintLayout>
RecycleViewのAdapterコード
class SampleRecyclerViewAdapter(private val itemDataList: List<String>) :
        RecyclerView.Adapter<SampleRecyclerViewAdapter.ViewHolder>() {
    override fun getItemCount(): Int = itemDataList.count()

    override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder? {
        parent ?: return null
        return ViewHolder(LayoutInflater.from(parent.context).inflate(
                R.layout.view_sample_recycler_item, parent, false))
    }

    override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
        holder?.itemView?.sampleRecyclerItemTextView?.text = itemDataList[position]
    }

    inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        init {
            itemView.setOnClickListener {
                Toast.makeText(
                        itemView.context,
                        itemView.sampleRecyclerItemTextView.text,
                        Toast.LENGTH_SHORT).show()
            }
        }
    }
}

RecycleViewのAdapterがListに入った文言をItemのレイアウトファイルを使ってリスト表示しています。 レイアウトのsampleRecyclerItemTextViewへはViewHolder内でitemView経由で参照します。

Kotlin Android Extensionsの挙動を理解しよう!

さて、とても便利なKotlin Android Extensionsですが、内部でどんなことをしているのでしょうか? Activityのサンプルコードを逆コンパイルしてJavaコードに変換して、どんなことをしているのか理解していきましょう。

public final class MainActivity extends AppCompatActivity
{

    public MainActivity()
    {
    }

    public void _$_clearFindViewByIdCache()
    {
        if(_$_findViewCache != null)
            _$_findViewCache.clear();
    }

    public View _$_findCachedViewById(int i)
    {
        if(_$_findViewCache == null)
            _$_findViewCache = new HashMap();
        View view1 = (View)_$_findViewCache.get(Integer.valueOf(i));
        View view = view1;
        if(view1 == null)
        {
            view = findViewById(i);
            _$_findViewCache.put(Integer.valueOf(i), view);
        }
        return view;
    }

    protected void onCreate(Bundle bundle)
    {
        super.onCreate(bundle);
        setContentView(0x7f09001b);
        ((Button)_$_findCachedViewById(R.id.button)).setOnClickListener((android.view.View.OnClickListener)new android.view.View.OnClickListener(this) {

            public final void onClick(View view)
            {
                view = (TextView)_$_findCachedViewById(R.id.textView);
                Intrinsics.checkExpressionValueIsNotNull(view, "this.textView");
                view.setText((CharSequence)"\u30CF\u30ED\u30FC\u30EF\u30FC\u30EB\u30C9\uFF01");
            }

            final MainActivity this$0;


            {
                this$0 = mainactivity;
                super();
            }
        }
);
    }

    private HashMap _$_findViewCache;
}

_$_findViewCache というハッシュマップのプロパティを用意して、Viewを管理しています。 参照時に_$_findViewCacheにViewインスタンスが無ければ、findeViewByIdをしてViewインスタンスを格納して参照し、すでに_$_findViewCacheにViewインスタンスがあれば、それを参照するという処理だということがわかりました。

注意点

Kotlin Android Extensions、とても便利ですが気をつけなければならない部分があります。

・誤って他の画面のUIへの参照するコードが書けてしまう。ビルドも通る。

Kotlin Android ExtensionsはData Bindingのようにコード生成しているのではなく、UIの要素をハッシュマップで管理しています。 誤って他の画面の要素を参照するコードを書いてしまっても、ビルドは正常に通ってしまうので気づけません。 実行時に_$_findCachedViewByIdでNullが返却されるため、NullPointerExceptionでクラッシュして、気づくことになります。 レイアウトファイルの要素のIDは他の画面の要素のIDが被ったり、どこの画面の要素なのかわからなくなることを防ぐために命名規則を設けておくと良いかもしれません。 例えば、「sampleActivityTextView」というような感じです。

・設計と相談

すでにデータバインディングを使っているような既存のプロジェクトではKotlin Android Extensionsを使っていくとなると設計を変えざるを得ないと思います。 使用する場合は設計と相談の上、ご使用ください。

まとめ

  • Kotlin Android Extensionsはjetbrains社製のAndroid開発支援プラグイン
  • Kotlin Android Extensionsは導入が簡単!
  • Kotlin Android Extensionsを使うとfindViewByIdを使わずに簡潔にUIを参照するコードが書けるようになる!
  • FragmentやRecyclerViewのAdapterなどでも使える!けど、Activityでのやり方とは違うので注意。
  • Kotlin Android ExtensionsはUIの要素をViewインスタンスとしてハッシュマップで管理している。
  • 間違って他の画面のUIを参照するとクラッシュするので命名規則などで回避。
  • 使う時は設計と相談。