みずきち日記

ひらすらプログラミング

Terminal 上で翻訳したい

gawk の導入。

$ curl -OL ftp://ftp.gnu.org/gnu/gawk/gawk-5.0.0.tar.gz
$ tar -zxvf gawk-5.0.0.tar.gz
$ cd gawk-5.0.0
$ ./configure
$ make clean ; make
$ ./gawk --version

gawk のパスを通す。

$ mv gawk-5.0.0 ~/gawk-5.0.0
$ export PATH=$PATH:/Users/s05463/gawk-5.0.0
$ gawk --version

translate-shell の導入。

$ git clone git clone https://github.com/soimort/translate-shell
$ cd translate-shell
$ make
$ sudo make install
$ trans -sl=en -tl=ja "Peace begins with a smile."

オプション多いので、alias を設定しておきます。

.zshrc

alias transj='trans -b -sl=ja -tl=en'
alias transe='trans -b -sl=en -tl=ja'

使う時はこんな感じ。

$ transe "hello"
> こんにちは

$ transj "こんにちは"
> Hello

参考サイト

qiita.com

qiita.com

qiita.com

StatusBar と ToolBar のあれこれ

今回の目標はコレです。 グラデーションのかかった Toolbar が StatusBar の下に潜り込んで表示されています。 そして、キーボードが表示された際の Activity のリサイズにも対応するようにします。

StatusBar と ActionBar

まずは、何も設定していない時はどうなるか見ていきます。

分かりやすいように EditText に赤い枠線を付けてます。 キーボードが EditText の上に覆いかぶさってしまっています。

これは、AndroidManifest の方で、android:windowSoftInputMode="adjustResize" を設定すると、キーボードが出現した際に Activity がリサイズされるようになります。

さらに、ActionBar を非表示にします。 Theme.AppCompat.Light.NoActionBar を Activity の Theme に指定すると非表示にできます。

次に、StatusBar の見た目をカスタマイズしていきたいと思います。 まずは、背景を透明にしていきます。

window.statusBarColor = Color.TRANSPARENT をすることで実現できます。

これで背景を透明にできました。しかし、これでは StatusBar の背景が白で、アイコンの色と被ってしまい見にくいので、LIGHT_STATUS_BAR にして、アイコンを黒にします。

window.statusBarColor = Color.TRANSPARENT
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
    val option = window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
    window.decorView.systemUiVisibility = option
}

 

StatusBar の下に Activity の View を食い込ませる

Activity の表示領域を StatusBar の範囲まで拡大させるためには、systemUiVisibility に対して View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE を設定します。

これは、以下のコードで実現できます。 こちらのサイトがとても参考になりました。

window.statusBarColor = Color.TRANSPARENT
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
    val option = window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
    window.decorView.systemUiVisibility = option
}

 

しかし、これだとキーボードが表示された時、 android:windowSoftInputMode="adjustResize" を設定しているのにも関わらず、 Activity のリサイズが効かなくなってしまいます。

そこで、Activity のルートタグに fitsSystemWindows="true" を指定することで、キーボードが出現した時に、Activity が必要に応じてリサイズされるようになります。

Toolbar を設置する

ここまで出来たら、次は Toolbar を設置していきます。

<androidx.appcompat.widget.Toolbar
    android:id="@+id/toolbar"
    android:layout_width="match_parent"
    android:layout_height="?attr/actionBarSize"
    android:background="@drawable/gradation"
    app:title="Toolbar"
    app:titleTextColor="#FFF" />

これを Activity に追加しておきます。

<?xml version="1.0" encoding="utf-8"?>
<layout>
    <LinearLayout 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"
        android:background="#00F"
        android:fitsSystemWindows="true"
        android:orientation="vertical"
        tools:context=".MainActivity">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="@drawable/gradation"
            app:title="Toolbar"
            app:titleTextColor="#FFF" />

        <androidx.appcompat.widget.AppCompatEditText
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_margin="16dp"
            android:background="@drawable/frame"
            android:gravity="top"
            android:hint="sample strings sample strings sample strings sample strings"
            android:padding="8dp"
            tools:ignore="HardcodedText" />
    </LinearLayout>
</layout>

ちなみに、背景の @drawable/gradation はこんな感じです。 gradient タグを使ってグラデーションを描きます。

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item>
        <shape android:shape="rectangle">
            <gradient
                android:angle="45"
                android:centerColor="#FF8C00"
                android:endColor="#FFEE00"
                android:startColor="#F60000" />
        </shape>
    </item>
</layer-list>

この状態で実行すると、Toolbar にグラデーションが描かれていると思います。 しかし、StatusBar にグラデーションがかかっていません。

 

これは、fitsSystemWindows="true" を設定した際に、StatusBar の高さ分だけ Padding が自動的に付与されるからです。

なので、以下の方法でステータスバーの領域までグラデーションをかけようと考えました。

  1. StatusBar の高さ分だけ Toolbar の高さを伸ばす
  2. StatusBar の高さ分だけ Toolbar に対してマイナスマージンをかける

StatusBar の高さは ViewCompat.setOnApplyWindowInsetsListener を使います。今回は汎用性をもたせて拡張関数として定義しました。

fun View.getStatusBarHeightPx(f: ((Int) -> Unit)) {
    ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets ->
        v.setOnApplyWindowInsetsListener(null)
        f(insets.systemWindowInsetTop)
        ViewCompat.onApplyWindowInsets(v, insets)
    }
}
private fun resizeToolbar() = with(binding) {
        root.getStatusBarHeightPx { statusBarHeightPx ->
            toolbar.updatePadding(top = statusBarHeightPx)
            toolbar.layoutParams = (toolbar.layoutParams as ViewGroup.MarginLayoutParams).apply {
                height += statusBarHeightPx
                updateMargins(top = -statusBarHeightPx)
            }
        }
    }

また、android:clipToPadding="false" を Activity のレイアウトに追加するのを忘れないでください。マイナスマージンをかけているので、Toolbar を親の View からはみ出して描画されるようにします。これでうまく動くはずです。

最後に、最低限必要なソースコードを載せておきます。

class MainActivity : AppCompatActivity() {

    private val binding by lazy {
        DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
    }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        initStatusBar()
        setContentView(R.layout.activity_main)
        resizeToolbar()
    }

    private fun initStatusBar() {
        window.statusBarColor = Color.TRANSPARENT
        if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            val uiOption = window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
            window.decorView.systemUiVisibility = uiOption
        }
    }

    private fun resizeToolbar() = with(binding) {
        root.getStatusBarHeightPx { statusBarHeightPx ->
            toolbar.updatePadding(top = statusBarHeightPx)
            toolbar.layoutParams = (toolbar.layoutParams as ViewGroup.MarginLayoutParams).apply {
                height += statusBarHeightPx
                updateMargins(top = -statusBarHeightPx)
            }
        }
    }
}

fun View.getStatusBarHeightPx(f: ((Int) -> Unit)) {
    ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets ->
        v.setOnApplyWindowInsetsListener(null)
        f(insets.systemWindowInsetTop)
        ViewCompat.onApplyWindowInsets(v, insets)
    }
}
<?xml version="1.0" encoding="utf-8"?>
<layout>
    <LinearLayout 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"
        android:background="#00F"
        android:clipToPadding="false"
        android:fitsSystemWindows="true"
        android:orientation="vertical"
        tools:context=".MainActivity">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="@drawable/gradation"
            app:title="Toolbar"
            app:titleTextColor="#FFF" />

        <androidx.appcompat.widget.AppCompatEditText
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_margin="16dp"
            android:background="@drawable/frame"
            android:gravity="top"
            android:hint="sample strings sample strings sample strings sample strings"
            android:padding="8dp"
            tools:ignore="HardcodedText" />
    </LinearLayout>
</layout>

何か、気づいたことや、懸念点あればコメント頂ければと思います。 参考になれば幸いです。

おしまい

Avtivity への遷移に SafeArgs を使ってみる

Android Jetpack のひとつに Navigation があります.

この機能を使えば複雑な Fragment 同士の画面遷移をGUI(xml)上で定義し,画面遷移をより簡潔に記述することが出来ます.

Fragment 間の画面遷移において,値渡しをする際は SafeArgs が便利です.

これは Navigation Graph の xml 内で対象 Fragment が必要とする引数を定義し,ビルド時に専用のクラスを生成し,それを使うことでより型安全に画面遷移を可能にする機能です.

今回は, 例のような Fragment --> Fragment だけでなく, Activity を立ち上げる際にも SafeArgs を使ってより型安全に画面遷移できる方法を考えてみます.

例として, 以下のような Activity があるとします.

class ResultActivity : AppCompatActivity() {

  companion object {
    private const val EXTRA_GITHUB_ID = "EXTRA_GITHUB_ID"

    fun createIntent(
      context: Context,
      githubId: String
    ): Intent {
      val intent = Intent(context, ResultActivity::class.java)
      intent.putExtra(EXTRA_GITHUB_ID, githubId)
      return intent
    }
    ...
}

よくある Activity を立ち上げるために必要な Intent を作成する方法として, 対象 Activity クラス内に static な createIntent メソッドを定義し, そこで Context やその他引数を受け取って, Intent に詰め込んで返す方法が一般的だと思います.

そして,呼び出し元では受け取った Intent を startActivity に渡します.

startActivity(ResultActivity.createIntent(this, "mzkii"))

この実装の問題点として

  • key:value 形式なので putExtra の key を一々自前で用意しないといけない

  • getExtra する際に渡す Key を間違えると予期しない値を受け取ってしまう可能性がある

などが挙げられると思います.

今回はこの部分を SafeArgs に置き換えて実装していきます.

まずはじめに navigation.xml を作っておきます.

<fragment> タグではなくて<activity> タグで括ります.

<navigation
    ...
    >
  <activity android:name="com.mzkii.dev.navigationactivities.ResultActivity">
    <argument
        android:name="userIds"
        app:argType="integer[]"
        />
    <argument
        android:name="userName"
        app:argType="string"
        />
  </activity>
  ...
</navigation>

この状態でビルドすると ResultActivityArgs が生成されます.

data class ResultActivityArgs(val userIds: IntArray, val userName: String) : NavArgs {
    fun toBundle(): Bundle {
        val result = Bundle()
        result.putIntArray("userIds", this.userIds)
        result.putString("userName", this.userName)
        return result
    }

    companion object {
        @JvmStatic
        fun fromBundle(bundle: Bundle): ResultActivityArgs {
            bundle.setClassLoader(ResultActivityArgs::class.java.classLoader)
            val __userIds : IntArray?
            if (bundle.containsKey("userIds")) {
                __userIds = bundle.getIntArray("userIds")
                if (__userIds == null) {
                    throw IllegalArgumentException("Argument \"userIds\" is marked as non-null but was passed a null value.")
                }
            } else {
                throw IllegalArgumentException("Required argument \"userIds\" is missing and does not have an android:defaultValue")
            }
            val __userName : String?
            if (bundle.containsKey("userName")) {
                __userName = bundle.getString("userName")
                if (__userName == null) {
                    throw IllegalArgumentException("Argument \"userName\" is marked as non-null but was passed a null value.")
                }
            } else {
                throw IllegalArgumentException("Required argument \"userName\" is missing and does not have an android:defaultValue")
            }
            return ResultActivityArgs(__userIds, __userName)
        }
    }
}

さらに, 専用の拡張関数も用意しておきます.

inline fun <reified T> Activity.startActivity(bundleArgs: Bundle) {
  val intent = Intent(this, T::class.java).apply {
    putExtras(bundleArgs)
  }
  startActivity(intent)
}
fun Activity.requireBundle() = intent.extras ?: throw IllegalArgumentException("Bundle() must not be null.")

これで準備は完了です.

Activity を立ち上げるときはこんな感じになります.

startActivity<ResultActivity>(
  ResultActivityArgs(
    userIds = intArrayOf(22, 23, 24),
    userName = "mzkii"
  ).toBundle()
)

ResultActivityArgs#toBundle() を使うと Bundle に引数の値を詰めて返してくれます.

そして, 受け取る側は以下のようになります.

onCreate 以降で使いたいので遅延初期化します.

class SafeargsActivity : AppCompatActivity() {

  private val args by lazy {
    ResultActivityArgs.fromBundle(requireBundle())
  }
  
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    ...
    val userIds = args.userIds
    val userName = args.userName
  }

こうすることで, IDE で補完の恩恵を受けつつ, createIntent の定義がなくなり, かつ間違った key を指定する事もなくなりました.

その一方で,

  • 必要な引数達を navigation.xml に集約して書くことになるので activity のコーディング時に必要な引数がぱっと見て分かりづらくなる
  • 拡張関数 startActivity の引数が Bundle なので ResultActivityArgs 使わなくても結局起動できてしまう
  • コードの自動生成が少なからず走るので, 画面数が増えた時にビルド時間が伸びそう

みたいなデメリットもあるな〜と感じました.

SafeArgs は結構便利なライブラリなので, Navigation 入れる前段階として徐々に導入していくと良いと思います.

iTerm2 で Full-Width Bottom of Screen

iTerm2 でウィンドウ上にターミナルを上に重ねて表示できることを今日知りました(驚愕).

基本的に以下のサイトの手順で設定できました.

https://oversleptabit.com/archives/1380

手順としては,

1. Window の表示設定をする

右下の Style を Full-Width Bottom of Screen, Screen を Screen with Cursor, Space を All Spaces に設定してます.画面上に重ねて表示するので,お好みで Transparency も適度に設定しておけばいいでしょう.

2. Terminal を表示するためのホットキーを設定する

ProfilesKeys を開き, Hotkey Window 欄にある A hotkey opens... にチェックを入れて Configure Hotkey Window をクリックします.

Hotkey の欄をクリックして Recoding... 状態になるので好きなホットキーを設定します.僕は command + 1 にしました.

Floating window にチェックを入れないと, Dock の高さ分だけ Terminal が浮いちゃうので付けてます.Pin hotkey window にチェックを入れると,キーボードのフォーカスが iTerm ではなくなっても表示され続けます.先に設定した Hotkey を押すと消えます.

3. 起動時の Window 設定

最後はお好みです.ここまでの設定だと, Desktop 上の画面下半分に Terminal が表示され続けます.デスクトップ上に居続けられると邪魔だったので, General の左上にある StartupOnly Restore Hotkey Window に設定して,ホットキーを押したときだけ Terminal が出てきてくれるようにしました.

ハマったポイントは以上です.

これで Android Studio 上の Terminal は使わなくて良くなったかな?

おすすめの設定があれば是非コメントなどで教えてください😉

おわり

EditText 中の任意の文字列に対してハイライト操作を行う

EditText に入力された文字列が存在したとして,任意の区間の文字列に対してハイライト表示したいケースを考えます. Twitterの文字数制限のUIみたいな感じです.

f:id:mzkii:20190224233829p:plain

TextView に対してハイライト処理するサンプルはいくつもありましたが, EditText に関するサンプルが少なく,少しハマったので紹介します.

まずはコードです.

入力可能な文字列の長さが INPUT_LIMIT 以内であれば,SUBMIT ボタンが押下可能になり,

INPUT_LIMIT を超えた場合は,超えた分の文字列をハイライトし,SUBMIT ボタンが押せなくなります.

class MainActivity : AppCompatActivity() {

    private val binding by lazy {
        DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
    }

    private val overLimitColorSpan = BackgroundColorSpan(Color.GRAY)
    private val defaultColorSpan = BackgroundColorSpan(Color.TRANSPARENT)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        with(binding) {
            submit.setOnClickListener { editText.text.clear() }
            editText.addTextChangedListener(object : TextWatcher {
                override fun afterTextChanged(s: Editable?) {
                    if (s == null) return
                    Spannable.Factory.getInstance().newSpannable(editText.text).apply {
                        if (s.length <= INPUT_LIMIT) {
                            removeSpan(overLimitColorSpan)
                            setSpan(defaultColorSpan, 0, s.length, getSpanFlags(defaultColorSpan))
                        } else {
                            removeSpan(defaultColorSpan)
                            setSpan(overLimitColorSpan, INPUT_LIMIT, s.length, getSpanFlags(overLimitColorSpan))
                        }
                    }.also {
                        val selectionStart = editText.selectionStart
                        val selectionEnd = editText.selectionEnd
                        editText.removeTextChangedListener(this)
                        editText.setText(it, TextView.BufferType.SPANNABLE)
                        editText.setSelection(selectionStart, selectionEnd)
                        editText.addTextChangedListener(this)
                    }
                    updateSubmit(editText.text)
                }

                override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
                override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
            })
        }
        updateSubmit("")
    }

    private fun updateSubmit(inputText: CharSequence) {
        with(binding) {
            val textDiff = INPUT_LIMIT - inputText.length
            val canSubmit = textDiff >= 0
            textCount.setTextColor(if (canSubmit) Color.BLACK else Color.RED)
            textCount.text = textDiff.toString()
            submit.isEnabled = canSubmit
        }
    }

    companion object {
        private const val INPUT_LIMIT = 10
    }
}

TextWatcher は,EditText に入力されている(された / あるいはされる前の)文字列の状態を監視するクラスです.今回は 入力後の文字列の状態を把握したいので,afterTextChanged に処理を書いています.

このコードでのポイントは,setText する前に removeTextChangedListener() を呼び出していることです.

TextWatcher クラスメソッド内部で setText を呼び出すと,キーボード入力した時と同様に再び ~Changed() メソッドが呼ばれてしまいループ状態に陥ってしまうので,setText する直前に一旦自分自身の Listener を削除しておき,setText 完了後に再び addTextChangedListener() で自分自身を登録します.

BackgroundColorSpan() は文字列の背景色を変えますが,ForegroundColorSpan() を使えば文字色を変更できたりします.

簡単な サンプルプロジェクト を用意しました. 参考になれば幸いです.

pyenv install 中に BUILD FAILED してしまう

Python のバージョン管理に pyenv を使っています.

Jango を使ってみようと,新しく Python バージョンを入れようとしました.

$ pyenv install 3.6.0
python-build: use openssl from homebrew
python-build: use readline from homebrew
Downloading Python-3.6.0.tar.xz...
-> https://www.python.org/ftp/python/3.6.0/Python-3.6.0.tar.xz
Installing Python-3.6.0...
python-build: use readline from homebrew

BUILD FAILED (OS X 10.14.3 using python-build 20180424)

Inspect or clean up the working tree at /var/folders/px/90g_3pjj7jndszmdtk9ybnqw0000gn/T/python-build.20190129040150.78545
Results logged to /var/folders/px/90g_3pjj7jndszmdtk9ybnqw0000gn/T/python-build.20190129040150.78545.log

Last 10 log lines:
  File "/private/var/folders/px/90g_3pjj7jndszmdtk9ybnqw0000gn/T/python-build.20190129040150.78545/Python-3.6.0/Lib/ensurepip/__main__.py", line 4, in <module>
    ensurepip._main()
  File "/private/var/folders/px/90g_3pjj7jndszmdtk9ybnqw0000gn/T/python-build.20190129040150.78545/Python-3.6.0/Lib/ensurepip/__init__.py", line 189, in _main
    default_pip=args.default_pip,
  File "/private/var/folders/px/90g_3pjj7jndszmdtk9ybnqw0000gn/T/python-build.20190129040150.78545/Python-3.6.0/Lib/ensurepip/__init__.py", line 102, in bootstrap
    _run_pip(args + [p[0] for p in _PROJECTS], additional_paths)
  File "/private/var/folders/px/90g_3pjj7jndszmdtk9ybnqw0000gn/T/python-build.20190129040150.78545/Python-3.6.0/Lib/ensurepip/__init__.py", line 27, in _run_pip
    import pip
zipimport.ZipImportError: can't decompress data; zlib not available
make: *** [install] Error 1

なぜか怒られます.

すぐに xcode-select --install で治るらしいと知ったのですが,

僕の場合は再度試しても同じでした.

いろいろ調べてみると,こちら の issue に解決策が載っていました.

sudo installer -pkg /Library/Developer/CommandLineTools/Packages/macOS_SDK_headers_for_macOS_10.14.pkg -target /

※ 現在 MAC OS Mojave を使っているので,たまたまバージョンが一緒だったので動きましたが,お使いの環境に合わせて変更したほうがいいと思います.

てことで,無事に Python3.6.0 が入り.

$ pyenv install 3.6.0
python-build: use openssl from homebrew
python-build: use readline from homebrew
Downloading Python-3.6.0.tar.xz...
-> https://www.python.org/ftp/python/3.6.0/Python-3.6.0.tar.xz
Installing Python-3.6.0...
python-build: use readline from homebrew
Installed Python-3.6.0 to /Users/fmzk/.pyenv/versions/3.6.0

django 用の仮想環境も作れました.

$ pyenv virtualenv 3.6.0 django3.6.0
Requirement already satisfied: setuptools in /Users/fmzk/.pyenv/versions/3.6.0/envs/django3.6.0/lib/python3.6/site-packages
Requirement already satisfied: pip in /Users/fmzk/.pyenv/versions/3.6.0/envs/django3.6.0/lib/python3.6/site-packages

作業用ディレクトリ配下で Python3.6.0 & 指定した仮想環境で実行できるように

$ pyenv local 3.6.0/envs/django3.6.0

とやっておしまい.

久々の更新でした(もっと頻度上げましょう)

Flux + Kotlin + RxJava + KOIN + Retrofit で作る Android アプリ

このエントリーはCyberAgent 19新卒 エンジニア Advent Calendar 2018の22日目の記事です.

はじめに

みなさんはじめまして, @mzkii です.

CA19新卒で,現在タップル誕生で AndroidEngineer として内定者バイトをしています.

この記事では,タップル誕生でも用いられている Flux Architecture について軽く知ってもらうために,GithubAPIを使った簡単なAndroidアプリケーションを開発していきたいと思います.

10分くらいで読める内容にしていますので,気になる方はぜひチャレンジしてみてください.

ちなみに完成形はこちらリポジトリで公開しています.

目標

以下の gif ファイルに表示されているような要件を満たすアプリを作ります.

captcha.gif

  1. login/oauth/access_token を叩きトークン取得
  2. /user/repos を叩き認証済みユーザーのRepository一覧を取得
  3. よしなにページング処理
  4. リポジトリ詳細に遷移可能

Flux Architecture

キーワードは 「単一方向のデータフロー」 です.

ユーザーからのイベントやAPI通信後に取得されるデータなど全て同じデータの流れに従い制御します.データの流れが一方向に制限されることによって,状態やロジック管理に関して無駄に悩むことが減ります.詳しくは公式サイトを見てみてください.

View(ボタンやスクロールイベント)👉ActionCreator(必要に応じてリモート等からデータを取得)👉Action(取得したデータを詰め込む)👉Store(状態を保持しViewに適切な形で公開)👉Viewの状態を更新

この流れを頭の片隅においておくと理解しやすいです.

画面

用意する画面を列挙します.

AuthorizeActivity

Action

まずActionを作っていきます.

ActionActionCreatorStoreとを結ぶデータの入れ物のような存在です.

sealed class AuthorizeAction {
    class Authorize(val accessToken: AccessToken) : AuthorizeAction()
}

ちなみに,AccessTokenは以下のようなDataClassです.

data class AccessToken(
    @field:Json(name = "access_token") val accessToken: String,
    @field:Json(name = "token_type") val tokenType: String
)

この画面では,認証ボタンを押すとAndroid標準のwebブラウザに遷移させるというアクションが必要なので,Authorizeというアクションクラスを用意しています.

後述するActionCreatorTokenを取得するためのAPIを叩き,結果をこのAuthorizeクラスのaccessTokenに代入し,dispatchすることでStoreに渡します.

ActionCreator

Authorizeアクションクラスを作成するActionCreatorは以下のようになっています.authorizeメソッドは今回Activityから呼ばれます.

class AuthorizeActionCreator(
    private val repository: AuthorizeRepository,
    dispatcher: Dispatcher
) : ActionCreator<AuthorizeAction>(dispatcher) {
    fun authorize(intent: Intent) {
        val uri = intent.data ?: return
        if (!uri.toString().startsWith(BuildConfig.REDIRECT_URI)) return
        repository
            .getAccessToken(uri.getQueryParameter("code")!!)
            .subscribeOn(Schedulers.io())
            .subscribeBy(
                onSuccess = { dispatch(AuthorizeAction.Authorize(it)) },
                onError = { Timber.e(it) }
            )
    }
}

ちなみに,AuthorizeActionCreatorが継承するActionCreatorクラスは以下のように定義されています.

今回マルチモジュールを使っていて, Flux モジュールに入れています.AuthorizeActionCreatorがこのActionCreator<T>を継承することで,AuthorizeActionCreatordispatchできるActionの型を制限しています.

abstract class ActionCreator<Action : Any>(
    private val dispatcher: Dispatcher
) {
    fun dispatch(action: Action) = dispatcher.dispatch(action)
}

ブラウザで認証させActivity#onResume()callbackして取得したIntent中に含まれるcoderepository.getAccessTokenメソッドに渡すと,アクセストークンを取得することができます.

repository
    .getAccessToken(uri.getQueryParameter("code")!!)
    .subscribeOn(Schedulers.io())
    .subscribeBy(
        onSuccess = { dispatch(AuthorizeAction.Authorize(it)) },
        onError = { Timber.e(it) }
    )

AccessTokenは以下のように定義されたDataClassです.

data class AccessToken(
        @field:Json(name = "access_token") val accessToken: String,
        @field:Json(name = "token_type") val tokenType: String)

onSuccessのタイミングで,取得したAccessTokenクラスをAuthorizeアクションクラスに詰め込みdispatchします.

dispatch(AuthorizeAction.Authorize(it))

ちなみにDispatcherは以下のように定義されています.

こちらもFluxモジュールに入れています.

class Dispatcher {
    private val bus = EventBus.builder()
        .throwSubscriberException(true)
        .build()

    fun dispatch(event: Any) {
        Timber.tag(this::class.java.simpleName).d("dispatch event=$event")
        bus.post(event)
    }

    fun register(observer: Any) {
        Timber.tag(this::class.java.simpleName).d("register observer=$observer")
        bus.register(observer)
    }

    fun unregister(observer: Any) {
        Timber.tag(this::class.java.simpleName).d("unregister observer=$observer")
        bus.unregister(observer)
    }
}

また後述しますが,このDispatcherKOINによってSingletonで管理されています.

ActionCreatordispatchすると,EventBusによって当該Store#on()メソッドにActionが流れてくる仕組みになっています.

AuthorizeStore

それではStoreの実装を見ていきましょう.

Storeは状態を保持し,Viewに対して保持した状態を適切な形に変換し公開します.

class AuthorizeStore(
    private val appSetting: AppSetting,
    dispatcher: Dispatcher
) : Store(dispatcher) {

    val gotoHomeState = StoreLiveData<Unit>()

    @Subscribe(threadMode = ThreadMode.MAIN)
    fun on(action: AuthorizeAction) { 👈
        when (action) {
            is AuthorizeAction.Authorize -> {
                appSetting.accessToken = action.accessToken.accessToken
                gotoHomeState.postValue(Unit) 👈
            }
        }
    }
}

AuthorizeActionCreator内でdispatch(AuthorizeAction.Authorize(it))すると,このStore#on()メソッドにAuthorizeActionが流れていきます.

今回は1つのアクションしか用意していないですが,通常複数のActionが流れてきますので,when (action)みたいな感じで分岐させます.当該スコープに入ると勝手にキャストされるので,action.(Actionのメンバ変数)としてアクセス可能です.

Store 👉 View については,LiveDataを使っています.StoreLiveDataStoreクラス内でのみpostValueが可能なカスタムLiveDataで,ボイラープレートコードを削減できます.Kotlininternal修飾子(他モジュールからは参照できない)という仕組みを利用して実現しています.StoreLiveDataはもちろんFluxモジュールに入れています.詳しくはこちらのブログを参考にしてみてください.

次にAuthorizeStoreクラスが継承しているベースのStoreクラスを見ていきます.

abstract class Store(
    private val dispatcher: Dispatcher
) : ViewModel() {
    init {
        register()
    }

    private fun register() {
        dispatcher.register(this)
    }

    override fun onCleared() {
        dispatcher.unregister(this)
        super.onCleared()
    }

    protected fun <T> StoreLiveData<T>.postValue(value: T) {
        internalPostValue(value)
    }

    protected fun <T> StoreLiveData<T>.setValue(value: T) {
        internalSetValue(value)
    }
}

Android Architecture ComponentViewModel クラスを使っています.これによってActivityStoreの生存期間を同一に保つことができます.(画面回転などによってActivityが再生成されてもStoreインスタンスは破棄されない)

View

最後にActivityです.DataBindingを使っています.by viewModel()とかby inject()とか書いてあるのはKOINで依存注入するための記述です.

AAC ViewModelStoreとして扱っているので,KOIN的にはAuthorizeStore by viewModel()となってしまい個人的に変な感じがします笑

class AuthorizeActivity : AppCompatActivity() {

    companion object {
        private const val URI = "https://github.com/login/oauth/authorize?client_id=" +
                "${BuildConfig.CLIENT_ID}&redirect_uri=${BuildConfig.REDIRECT_URI}"
    }

    private val store: AuthorizeStore by viewModel()
    private val actionCreator: AuthorizeActionCreator by inject()

    private val binding: ActivityAuthorizeBinding by lazy {
        DataBindingUtil.setContentView<ActivityAuthorizeBinding>(this, R.layout.activity_authorize)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_authorize)
        initView()
        observeState()
    }

    override fun onResume() {
        super.onResume()
        actionCreator.authorize(intent) 👈2
    }

    private fun initView() {
        binding.authorizeButton.setOnClickListener {
            startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(URI))) 👈1
        }
    }

    private fun observeState() {
        store.gotoHomeState.observe(this) {
            finish()
            startActivity(Intent(application, MainActivity::class.java)) 👈4
        }
    }
}

流れとしては,

  1. authorizeButtonが押され,Android標準のWebブラウザが立ち上がる.(User Interaction)
  2. 認証が済んでcallbackしてきたタイミングでonResumeが呼ばれ,そのまま actionCreator.authorize(intent) をコールする(View 👉 ActionCreator)
  3. ActionCreator内部でアクセストークンが取得できればdispathし,AuthorizeStore#on()メソッドにトークン情報が入ったActionが流れてくる.(ActionCreator 👉 Store)
  4. StoreHome画面に遷移する為のLiveDataUnitを渡して,ActivitygotoHomeState.observe(this)が呼ばれMainActivityに遷移する(Store 👉 View)

みたいな感じです.これで,アプリ起動からアクセストークン取得までの流れを掴むことができました.

AuthorizeStoreの方でappSetting.accessToken = action.accessToken.accessTokenと記述しているのは,SharedPreferencesにアクセストークンを保存するためです.今回少し拡張してAppSettingというクラスを作ってみました.こちらもKOINSingletonに管理されています.今後認証が必要なAPIを叩くときはAppSettingに保存されたaccessTokenを使います.

ここまでが認証画面のデータフローに関する説明です.

MainActivity

この画面は,認証済みユーザーのリポジトリ一覧を取得しリスト表示するための画面になります.

HomeAction

リスト表示するだけなので,基本的にはList<Repository>をラッパーしたActionを用意すればいいです.今回はローディング表示もさせたかったのでShowLoadingというActionも作っています.

こんな感じに,この画面ではどんなユーザーインタラクションが発生するだろう,と考えつつ最初にActionを作ったほうが個人的には楽だと思います.

sealed class HomeAction {

    class ShowLoading(val isLoading: Boolean) : HomeAction()
    class LoadRepositoryList(val repositoryList: List<Repository>) : HomeAction()
}

HomeActionCreator

基本的には,リポジトリ一覧を読み込んで,LoadRepositoryListにデータを詰め込んで,dispathしてStoreに送るだけです.

今回ローディングを表示する処理も入れているので,適切なタイミングでShowLoadingdispathしてやります.

こうすることで,ShowLoading(true) 👉 LoadRepositoryList(リポジトリ一覧データ) 👉 ShowLoading(false) という順番でHomeStoreHomeActionが流れてくることになります.

class HomeActionCreator(
    private val appSetting: AppSetting,
    private val repository: GithubRepository,
    dispatcher: Dispatcher
) : ActionCreator<HomeAction>(dispatcher) {

    fun getMyRepositoryList(page: Int) =
        repository
            .getMyRepositoryList(appSetting.accessToken, page)
            .subscribeOn(Schedulers.io())
            .doOnSubscribe { dispatch(HomeAction.ShowLoading(true)) }
            .doFinally { dispatch(HomeAction.ShowLoading(false)) }
            .subscribeBy(
                onSuccess = { dispatch(HomeAction.LoadRepositoryList(it)) },
                onError = { Timber.e(it) }
            )
}

HomeStore

次にStoreです.AuthorizeStoreと原理は一緒ですが,ページング処理が入っています.

Store#on()に流れてくるリポジトリデータは断片的なもので今回は10件ずつしか入ってきません.そこでStore内部で,これまで読み込んだリポジトリデータを保持しておき,毎回データが流れてくるたびにAddAllします.

後はAddAllしたリストをLiveDataViewに流すだけです.またHomeAction.LoadRepositoryListが流れてくるたびに,次のページ設定と,次ページ以降データが存在するかのフラグを立てておきます.View側でリポジトリをフェッチするActionCreatorを叩く際に必要になってきます.

class HomeStore(dispatcher: Dispatcher) : Store(dispatcher) {

    companion object {
        private const val INITIAL_PAGE = 1
    }

    private val repositoryList = mutableListOf<Repository>()

    var canFetchMore = false
        private set

    var pageNum = INITIAL_PAGE
        private set

    val loadingState = StoreLiveData<Boolean>()
    val loadedRepositoryListState = StoreLiveData<List<Repository>>()

    @Subscribe(threadMode = ThreadMode.MAIN)
    fun on(action: HomeAction) {
        when (action) {

            is HomeAction.ShowLoading -> {
                loadingState.postValue(action.isLoading)
            }
            is HomeAction.LoadRepositoryList -> {
                canFetchMore = action.repositoryList.isNotEmpty()
                pageNum++
                repositoryList.addAll(action.repositoryList)
                loadedRepositoryListState.postValue(repositoryList)
            }
        }
    }
}

View

最後にActivityです.initView()から見ていきましょう.recyclerViewの初期化が中心ですが,リストが最下部に到達したかどうかのタイミングが知りたいのでFetchMoreScrollListenerを自作しています.

最下部に到達し,かつ次ページのデータが存在する場合に,ctionCreator.getMyRepositoryList(store.pageNum)をして次ページを読み込みます.

そうすると,既存の(ロード済みの)リポジトリリストに,新しくフェッチしたリポジトリリストを加えたリストがloadedRepositoryListStateに流れてきます.

observeState()ではloadingStateの購読と,loadedRepositoryListStateの購読を行っています.

取得したリポジトリリストはloadedRepositoryListStateに流れてくるので,adapter.submitList(it)を実行します.リストの追加やアニメーションは内部でよしなにやってくれます.

class MainActivity : AppCompatActivity() {

    private val store: HomeStore by viewModel()
    private val actionCreator: HomeActionCreator by inject()

    private val binding: ActivityMainBinding by lazy {
        DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
    }

    private val repositoryAdapter: RepositoryAdapter by lazy {
        RepositoryAdapter(
            onCardClick = { repository ->
                repository.url?.let { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it))) }
            }
        )
    }

    private var isLoading = false

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        initView()
        observeState()
        isLoading = true
        actionCreator.getMyRepositoryList(1)
    }

    private fun initView() {
        title = "My repositories"
        with(binding.recyclerView) {
            adapter = repositoryAdapter
            layoutManager = LinearLayoutManager(this@MainActivity, RecyclerView.VERTICAL, false)
            addOnScrollListener(object : FetchMoreScrollListener() {
                override fun onLoadMore() {
                    if (!isLoading && store.canFetchMore) {
                        isLoading = true
                        actionCreator.getMyRepositoryList(store.pageNum)
                    }
                }
            })
        }
    }

    private fun observeState() {
        store.loadingState.observe(this) {
            binding.progressBar.visibility = if (it) View.VISIBLE else View.GONE
            isLoading = it
        }
        store.loadedRepositoryListState.observe(this) {
            repositoryAdapter.submitList(it.toList())
        }
    }
}

以上がリポジトリ一覧画面の解説になります.

その他

今回使用したライブラリ一覧を列挙します. KOINとRetrofitに関しては解説も入れておきます.

    // EventBus
    implementation 'org.greenrobot:eventbus:3.1.1'

    // Timber
    implementation 'com.jakewharton.timber:timber:4.7.1'

    // KOIN
    implementation 'org.koin:koin-android:1.0.2'
    implementation 'org.koin:koin-android-scope:1.0.2'
    implementation 'org.koin:koin-androidx-scope:1.0.2'
    implementation 'org.koin:koin-android-viewmodel:1.0.2'
    implementation 'org.koin:koin-androidx-viewmodel:1.0.2'

    // Network
    implementation 'com.squareup.retrofit2:retrofit:2.4.0'
    implementation 'com.squareup.retrofit2:adapter-rxjava2:2.4.0'
    implementation 'com.squareup.retrofit2:converter-moshi:2.4.0'
    implementation 'com.squareup.okhttp3:okhttp:3.11.0'

    // JsonParser
    implementation 'com.squareup.moshi:moshi:1.7.0'
    implementation 'com.squareup.moshi:moshi-kotlin:1.7.0'

    // Rx
    implementation 'io.reactivex.rxjava2:rxjava:2.2.2'
    implementation 'io.reactivex.rxjava2:rxandroid:2.1.0'
    implementation 'io.reactivex.rxjava2:rxkotlin:2.3.0'

    // CardView
    implementation 'com.android.support:cardview-v7:28.0.0'

KOIN

KOIN は Android 向けのシンプルな Dependency Injection フレームワークで,Kotlinの機能を使ってDIを実現しています.

このアプリでは,Applicationクラス内で以下のようなモジュールを定義しています.

private val appModule = module {
    single { Dispatcher() }
    single { AppSetting(getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)!!) }
}

private val networkModule = module {
    single {
        Retrofit
            .Builder()
            .client(OkHttpClient.Builder().build())
            .baseUrl("https://github.com/")
            .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
            .addConverterFactory(MoshiConverterFactory.create())
            .build()
            .create(AuthorizeApi::class.java)
    }
    single {
        Retrofit
            .Builder()
            .client(OkHttpClient.Builder().build())
            .baseUrl("https://api.github.com/")
            .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
            .addConverterFactory(MoshiConverterFactory.create())
            .build()
            .create(GithubApi::class.java)
    }
    single { AuthorizeRepository(get()) }
    single { GithubRepository(get()) }

}

private val uiModule = module {
    factory { HomeActionCreator(get(), get(), get()) }
    viewModel { HomeStore(get()) }

    factory { AuthorizeActionCreator(get(), get()) }
    viewModel { AuthorizeStore(get(), get()) }
}

Singletonで扱いたいクラスはsingleに,factoryを使えばget()が呼び出されるたびに毎回新しいインスタンスを提供します.

依存関係はget()で解決します.例えば,HomeActionCreatorAppSettingGithubRepositoryDispatcherが必要ですが,AppSettingappModule内でSingletonとして定義され,GithubRepositorynetworkModule内でSingletonとして定義され(かつGithubRepositoryGithubApiが必要ですが同一モジュール内でSingletonで提供されます),DispatcherappModule内でSingletonで提供されます.

AAC ViewModelの場合はviewmodelを使います.Daggerと違ってKOINは標準でAAC ViewModelをサポートしています.

Field Injectionする場合は,by inject()を使います.AAC ViewModelの場合はby viewmodel()なので注意です.

これでmoduleが作れたので,後はApplicationクラス内のonCreateのタイミングでstartKoinメソッドをコールし,先程作成したmoduleを指定します.

startKoin(this, listOf(appModule, uiModule, networkModule))

Retrofit

RetrofitはmoshiとRxJavaを組み合わせて使っています.

Retrofit
    .Builder()
    .client(OkHttpClient.Builder().build())
    .baseUrl("https://api.github.com/")
    .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
    .addConverterFactory(MoshiConverterFactory.create())
    .build()
    .create(GithubApi::class.java)

OAuth認証をするにあたって少しハマったところを書きます.

interface AuthorizeApi {
    @Headers("Accept: application/json")
    @POST("login/oauth/access_token")
    @FormUrlEncoded
    fun getAccessToken(
        @Field("client_id") clientId: String,
        @Field("client_secret") clientSecret: String,
        @Field("code") code: String) : Single<AccessToken>
}

RetrofitでAPIごとにヘッダーを設定する場合,@Headersを付けるみたいです. 今回はjsonで欲しかったのでAccept: application/jsonとしました. この設定を忘れてしまいかなり時間を食ってしまいました.

さらに,Retrofitを使用してフォームURLエンコードされたリクエストを実行するために@FormUrlEncodedも付け加えておきます.

ここら辺のアノテーション周りの情報についてはFuture Studioの動画がとても参考になりました(Thank you!!).また,GithubOAuth認証周りについては,詳しくはAuthorizing OAuth Appsを参照してください.

終わりに

いくつかモダンなライブラリを使いつつ,Flux ArchitectureでAndroidアプリを作ってみました!10分で読めると言いつつ,気がつけばかなり分量が多くなってしまい反省してます😅

Flux は状態管理がしやすく,初めて見る画面でもActionを見ればどんなことをする画面なのかなんとなく把握できたりします.さらに,単一方向のデータフローに強制することで,誰が書いても同じようなコードになりやすく,責務が分割されやすく,コードの保守性が向上します.

この記事を読んで少しでも興味を持たれた方は, Android/iOSにかかわらずぜひFluxに挑戦してみてください. (※完成形はこちらリポジトリで公開しています)

おしまい