副業で開発したAndroidアプリのお話し

本記事は [個人開発 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/private-development) 11日目の記事です。

## 概要
個人開発ではないですが、副業でAndroidアプリ開発した時のお話しです。
私は、普段（主務で）サーバサイド開発がメインですが、
以前はAndroidアプリ開発を2〜3年くらいやっていました。
かなり久々の状態でAnroidアプリをフルスクラッチ開発した時のお話しを書いてみようかと思います。

## 開発したアプリ
**[Runtrip](https://play.google.com/store/apps/details?id=jp.runtrip.app)** という `走るモチベーションになるランナー向けSNSアプリ` のAndroidアプリを1から開発しました。

## 開発体制
* 下記の3名体制
    * 本業の方が1名: この方も以前はAndroid開発をしたことあり
    * 私: 以前はAndroid開発していたが、1から作るのは久しぶり
    * 副業メンバーがもう1人: Androidアプリ開発をしたことはあるがかなり久々
* このように普段はAndroid開発をしていないメンバーで開発を始め、約4ヶ月間でリリース^^

## 開発方針
* 開発する我々が久々にやる人達だったこともあり、下記の方針で開発
    * Googleさん純正のものをなるべく使う
    * 全部自分たちで作ろうとせず、OSSに頼るところは頼る
    * 使用するものはなるべく新しいバージョンを使うこと
* 特別な実装をしなければいけない際は話し合うようにしてましたが、基本的にはこの方針に従いつつ、画面単位で担当を決めて開発を進めていきました

## 使用したもの
そんな方針の中で使用したもの一部が下記になります。

| 対象 | 使用物 |
|:-----------------|:------------------|
| 言語 | Kotlin |
| DI | [Dagger2](https://github.com/google/dagger) |
| 画像処理 | [Glide](https://github.com/bumptech/glide) |
| 通信処理 | [Retrofit](https://square.github.io/retrofit/), [OkHttp](https://square.github.io/okhttp/) |
| リアクティブ | [RxAndroid](https://github.com/ReactiveX/RxAndroid) |
| jsonライブラリ | [moshi](https://github.com/square/moshi/) |
| pub/sub | [EventBus](https://github.com/greenrobot/EventBus) |
| ログ | [Timber](https://github.com/JakeWharton/timber) |
| メモリリーク検出ツール | [LeakCanary](https://github.com/square/leakcanary) |

一部抜粋ですが、これでもかというくらい世の中のAndroidアプリ開発で使われているもので開発を行いました。そのおかげで大分開発しやすかったので、これらのOSSに感謝しかないですmm
そして、以前自分が開発していた時に比べると、上記に加えてAndroid標準で用意してくれているものを組み合わせればかなり開発しやすくなったなと実感する日々だったことを思い出します。

あとは、[ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel?hl=JA)を使いつつ、レイヤードアーキテクチャーチックな構成で開発してみました。

## ちょっとした苦労話
こちらのアプリ、基本的にはこれまでに話させて頂いたものを使わせて頂いたおかげで、そんなに苦労せず？に開発することができましたが、2点だけ苦労した部分があります。
その部分は、下記の `ジャーナル` と言われるものの開発で、

![20191208_144229.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71637/e108f374-872f-192d-5f05-93bff7444b0a.gif)

その中でも、下記の2点に時間がかかりました。

1. ジャーナル投稿部分
2. ジャーナル一覧の画像表示処理

1つ目に関しては、自分が担当しなかったので（いずれ関わった誰かが苦労話を書くと信じて割愛）、2点目の画像表示部分についてちょっとだけ書きたいと思います。

### 仕様
* 仕様としてちょっとだけ大変だったのが下記
    * 画像は下記の3タイプがある
        * 正方形
        * 縦長の長方形
        * 横長の長方形
    * これはジャーナル投稿時にユーザが選択する
    * 一覧で表示する際は、画像の縦横比をいい感じに計算して、それぞれに合わせて表示する
* つまり、縦長の画像の場合は、画像の横の長さを端末の横幅に合わせつつ、一覧上では縦長に表示されるといった処理が必要でした

### 利用ライブラリ
* 利用しているのは下記
    * 画像表示は `Glide`
    * リスト表示は `RecyclerView`
* `RecyclerView` の表示サイクルに合わせつつ、画像サイズを適宜計算して表示

### 思いついた方法
* Android開発が久々の自分が思いついた方法は下記の3つでした
    1. `RecyclerView.Adapter` の `onBindViewHolder` でよしなにできない？
    2. `Glide` のcallbackでよしなにできない？
    3. `ImageView` のライフサイクルでよしなにできない？
* Android開発に詳しい方や日頃から開発している方は `これでいけるじゃん！` というのを思いつくかもしれませんが、久々の自分はこれを全て試してみました＞＜

#### RecyclerView.AdapterのonBindViewHolder
実装イメージは下記のような感じ

```kotlin
class JournalAdapter(<<省略>>) : RecyclerView.Adapter<BindingViewHolder> {
  /////
  override fun onBindViewHolder(holder: BindingViewHolder, position: Int) {
    // holderのImageViewを、読み込んだ画像の大きさをいい感じに計算して表示
  }
  /////
```
・・・・当たり前ですが、Bindした段階で画像が読み込まれていない可能性があるので、この段階で計算しても大きさが0とかになり得るので、この方法ではダメ（じゃない方法があるかもしれませんが、このままだとダメでした＞＜）

#### Glideのcallback
Glideには画像を読み込んだ際のCallback関数 `onResourceReady` を使ってみたのが下記。

```kotlin
// imageViewという変数にImageViewがある想定
GlideApp.with(context)
  .load(url)
  .listener(object : RequestListener<Drawable> {
    override fun onLoadFailed(
      e: GlideException?,
      model: Any?,
      target: Target<Drawable>?,
      isFirstResource: Boolean
    ): Boolean {
      return false
    }

    override fun onResourceReady(
      resource: Drawable?,
      model: Any?,
      target: Target<Drawable>?,
      dataSource: DataSource?,
      isFirstResource: Boolean
    ): Boolean {
      resource?.let {
        val params = this@imageView.layoutParams 
        params.width = resource.intrinsicWidth
        params.height = resource.intrinsicHeight
        this@imageView.layoutParams = params
        this@imageView.invalidate()
      }
      return false
    }
  })
  .transition(DrawableTransitionOptions.withCrossFade())
  .into(imageView)
```
→ この書き方、一見すると良さそうでしたが、これはあくまで `読み込みが完了したタイミングで呼ばれる` ので、その前に描画されるとこの計算は反映されずorz

#### 各種callbackの整理
* 一度各種callbackがどのような順序で呼ばれているか、特にadapter、Glide、ImageViewで、それぞれどのような順番で呼ばれるか見てみました
  * adapter.**onBindView**
  * ImageView.**onMeasure**
  * (画像の読み込み完了時) Glide.**onResourceReady**
* 計算がどのようにされるかというと、
  * `onBindView` でImageViewを計測すると、高さも横も0となる（要は画像が読み込めてない）
  * ImageView(正確にはView)の `onMeasure` だと、最初は表示する `drawable` がないのでnullになる（ここも画像が読み込めていない状態)
  * onResourceReadyで表示すべき画像の大きさがわかり、高さと横などがわかる
  * そのあとにレイアウトで表示される領域などが決まるので、再度ImageViewの `onMeasure` が呼ばれる
* これらのことから、onResourceReadyが読み終わったあとであれば、onBindViewでもimageViewの縦横はある
  * が、、、そのImageViewの高さと鵜呑みにしてはいけないTT
  * それは **前回表示したもの** の高さや横の長さになっている可能性がある（RecyclerViewは、一度使われたもののlayoutは再利用されるため）
* Glideの場合、cacheから取得した画像にしろ、リモートから読み込んだ画像にしろ、読み込んだタイミングのcallbackとして、 `onResourceReady` が読み込まれる

#### ImageView(View)のcallback
上記の整理から、ImageView(View)の `onMeasure` で計算すれば良いのでは？ということで、下記のような処理を入れてみました

```kotlin
class CustomImageView : AppCompatImageView {
  /////
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    
    // Parent width ( this case, device width )
    val width = MeasureSpec.getSize(widthMeasureSpec)
    if (drawable != null) {
      when {
        // Square image
        drawable.intrinsicWidth == drawable.intrinsicHeight -> {
          // deviceの横幅より小さい画像の場合の対応
          if (drawable.intrinsicWidth < width) {
            setMeasuredDimension(width, width)
          } else {
            setMeasuredDimension(drawable.intrinsicWidth, drawable.intrinsicHeight)
          }
          this.invalidate()
        }

        // Portrait image
        drawable.intrinsicHeight > drawable.intrinsicWidth -> {
          val height: Int = (drawable.intrinsicHeight * width) / drawable.intrinsicWidth
          setMeasuredDimension(widthMeasureSpec, height)
          this.invalidate()
        }

        // Landscape image
        else -> {
          val height: Int = (drawable.intrinsicHeight * width) / drawable.intrinsicWidth
          setMeasuredDimension(widthMeasureSpec, height)
          this.invalidate()
        }
      }
    } else {
      setMeasuredDimension(widthMeasureSpec, widthMeasureSpec)
    }
  }
  /////
}
```
・・・・うまく表示されたーーー！!(と喜んだことを覚えてますw)
現状、縦長と横長は同じ処理で表示される（基本的には端末の横幅に画像幅を合わせ、それに応じて縦幅を変えるのみ）ので、そこで処理をわけなくても良いのですが、あとで見た人でもわかりやすいようにわけております。

## 最後に
個人開発ではないですが、副業でAndroidアプリ開発をしてみた話を書かせて頂きました。
画像表示部分、もっとうまくやれる方法があるかもしれませんが、自分の思いつく中では上記が限界でした（時間的にも知識的にも＞＜）ので、
もし「こうするともっとうまくできます！」などありましたら、是非アドバイスいただけると嬉しいです！！

そして最後まで読んで頂きまして、誠にありがとうございましたmm

## 参考
* [Android入門 アプリ開発の基本、ビューのライフサイクル(図解)](https://www.ecoop.net/memo/archives/android_lifecycle_of_view.html)