ダークモード対応する際の見積もり手法

## 挨拶

こんにちは、ソーシャル経済メディア「NewsPicks」のモバイルアプリチームでAndroidを担当していますsefwgweoです。

この記事は NewsPicks アドベントカレンダー 2023 の4日目の記事です。

## 概要

今年、Android版Newspicksのダークモードを半年がかりでリリースした時にどのようにして見積もったかを実例をまじえて振り返ろうと思います

デザイナチームによるまとめは[こちら](https://note.com/akanyoshi/n/na18930c1808f)

iOSは本プロジェクトでモバイルチームとして陣頭指揮をとってくれた[@_asa08_](https://qiita.com/_asa08_)さんが後日記載予定ですのでお楽しみに！

## 前提
Android版Newspicksは10年以上サービスが続いているため、見積もる際以下な問題をはらんでいました
- 画面数が多い
- 機能数が多い
- 仕様が複雑

## 見積もり方法
### 見積もる単位は１画面毎

前提にあるように、そもそも対応が必要な画面数がとても多かったためまずはチームメンバー全員で全ての画面(ダイアログ含む)のスクショを撮りNotionにまとめました。
枚数としてはおよそ240枚ほどで、そもそも対象画面を出すのもひと手間必要な画面もありました。
また、スクショだけだと見積もりはもとよりダークモード対応実作業者にも優しくないため、初期段階では対応画面クラス名の記載及び必要に応じて画面到達方法を入れるというルールで進行しました。

### １画面にかかるコストをパターン化

無事スクショ一覧が完成後は、見積もりに必要な項目や懸念を洗い出すため約1ヶ月ほど1人が稼働の3割〜5割を使ってダークモード対応を行いました。
この1ヶ月があったおかげでかなり精度のよい見積もりが出来たと思っています。

結果、以下のような4つのケースに分類できることがわかりました。

1. 1画面につき30分程度で完了する最もライトなケース(全体の2割程度)
1. 1画面につき60分程度で完了する最も対応が多かったケース（全体の4割程度）
1. 1画面につき120分程度で完了する2番目に多かったケース（全体の3割程度）
1. 1画面につき半日〜１日で完了する最も重いケース（全体の1割以下）

#### 見積もり時間の内訳
PullRequestにBefore/Afterのスクリーンショットを貼ってデザイナにOKをもらい、masterブランチにマージされるまでの時間としています

### ケースの具体
#### 最もライトな30分のケースについて
変更がXMLのみで完結し、かつ対応箇所も多くない（１桁程度）ケースにおいては３０分と定義しました。
具体的には以下のような内容です。

修正前XML(一部を抜粋)
```xml
<androidx.constraintlayout.widget.ConstraintLayout
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:background="@color/black_600">
  <ImageView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:srcCompat="@drawable/ic_tab_setting".. />
  <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textColor="@color/gray_70".. />
  <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textColor="@color/gray_70".. />
</androidx.constraintlayout.widget.ConstraintLayout>
```

修正後XML(一部を抜粋)
```xml
<androidx.constraintlayout.widget.ConstraintLayout
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:background="@color/surface_base_default">
  <ImageView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:srcCompat="@drawable/label_tab_setting".. />
  <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textColor="@color/text_base_secondary".. />
  <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textColor="@color/text_base_secondary".. />
</androidx.constraintlayout.widget.ConstraintLayout>
```

実際のPullRequestに貼ったスクリーンショット
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/541072/3fc2f0a7-247b-e5e5-00f0-47dbb410b222.png" width="400" />

作業内容としては以下です
- 文字色をダークモードも対応しているカラーパレットのものにする
- 画面背景色をダークモードも対応しているカラーパレットのものにする
- 画像をライトモードとダークモード用にFigmaからダウンロードし、drawable配下とdrawable-night配下に置いたものを参照するようにする
- ストア版、修正したライトモード版とダークモード版の３種類のスクリーンショットを撮ってPullRequestに貼る
- デザインOKが出たらPullRequestをマージする

#### 最も対応が多かった60分のケースについて
１画面内に何らかのアクションをするパーツがあると変更箇所と確認箇所が増えるため、この６０分のケースが対応数が最も多い結果となりました。
例えば「いいね」をダークモード対応しようとすると、ONの状態・OFFの状態・タップ時のアニメーションと３つのケースを確認する必要があります。
弊社ではアニメーションは初期の頃から両OS共Lottieを用いておりダークモードに対応する時少し苦労しました。
詳しくは[こちらの記事](https://qiita.com/sefwgweo/items/c0c0b28bf71ddb606af0)にまとめてあります

作業内容としては上記の通り最もライトなケースにプラスアルファでアクションパーツがあるケースが大半で、スクリーンショットとデプロイゲートを配布して実挙動を確認してもらう手間が追加される感じでした
特にアニメーション等は実際に動いている状態を見て差し替え、ということもあったため、平均的に６０分程度かかっていました。

#### 2番目に対応が多かった120分のケースについて
最も対応数が多かった60分のケースとの違いとしては大きく２パターンありました
１つは、画面自体作成されたのが結構昔で実装が古すぎるケース、もう１つは技術的な問題に当たって時間がかかったケースです。

前者で一番面倒だったのは、CustomViewで共通的にパーツを使いまわしていて該当画面で直すと他でも影響が出てしまう場合でした。この場合は基本的にはCustomViewを使わずに作り直す必要があったため、時間がかかりました

後者は例えば以下のようなことがありました。

OS13以上の端末でButtonレイアウトにborderlessButtonStyleを用いていると、backgroundを指定しているのに色が反映されなかった

以下NGだったXML
```xml
<Button
  style="?android:attr/borderlessButtonStyle"
  android:background="@{@drawable/bg_radius_34dp_subtle_button}".. />
```

以下OKだったXML
```xml
<androidx.appcompat.widget.AppCompatButton
  style="?android:attr/borderlessButtonStyle"
  android:background="@{@drawable/bg_radius_34dp_subtle_button}".. />
```

作業内容としては上記の通り60分のケースにプラスアルファで技術的な調査や作り直しのコスト分で平均的に120分程度かかっていました。


#### 最も時間がかかった半日〜１日のケースについて
半日以上かかるケースというのは実際は２件しかなく、原因は古い実装の箇所でした。
具体的にはタブ機能が独自に１クラス内で実装されており、画面単位での見積もりとしたためタブの数だけ対応する必要が出た、という感じでかなりイレギュラーかと思います。


## まとめ
上記をふまえて1つ1つ反映してようやく約4人月と算出できました（お試し期間は除いています）。
個人的にはスタートからリリースまで約半年というスパンでの見積もりが初めてだったため、色々不安でしたが大きく予定がズレることもなく、途中別のエンジニアがアサインされてもスケジュール通り進行できたので満足でした。


## おまけ:ダークモード対応時ハマったこと
### アルファ値ありの枠線レイアウトをFrameLayoutで上から被せると期待通りの色にならなかった

以下NGだったXML(画像を角丸にし、角丸の枠線をつける)
```xml
<androidx.constraintlayout.widget.ConstraintLayout..>
  
  <androidx.cardview.widget.CardView..>
    <androidx.appcompat.widget.AppCompatImageView.. />
  </androidx.cardview.widget.CardView>

  <!-- gray色枠線背景 -->
  <FrameLayout
    android:background="@drawable/bg_radius_4dp_stroke_border_gray"../>
</androidx.constraintlayout.widget.ConstraintLayout>
```

以下OKだったXML(画像を角丸にし、角丸の枠線をつける)
```xml
<androidx.constraintlayout.widget.ConstraintLayout..>

    <!-- gray色枠線背景 -->
  <FrameLayout
    android:background="@drawable/bg_radius_4dp_stroke_border_gray"../>

  <androidx.cardview.widget.CardView..>
    <androidx.appcompat.widget.AppCompatImageView.. />
  </androidx.cardview.widget.CardView>

</androidx.constraintlayout.widget.ConstraintLayout>
```

### ダーク/ライト切り替え方法

[こちら](https://stackoverflow.com/questions/41391404/how-to-get-appcompatdelegate-current-mode-if-default-is-auto)を参考に以下のようにダークモードかどうかを判定するようにしました

```kotlin
fun isDark(): Boolean {
    val defaultNightMode = AppCompatDelegate.getDefaultNightMode()
    if (defaultNightMode == AppCompatDelegate.MODE_NIGHT_YES) {
        return true
    }
    if (defaultNightMode == AppCompatDelegate.MODE_NIGHT_NO) {
        return false
    }

    // システム設定に従うケース
    val currentNightMode: Int = (context.resources.configuration.uiMode
        and Configuration.UI_MODE_NIGHT_MASK)
    when (currentNightMode) {
        Configuration.UI_MODE_NIGHT_NO -> return false
        Configuration.UI_MODE_NIGHT_YES -> return true
        Configuration.UI_MODE_NIGHT_UNDEFINED -> return false
    }
    return false
}
```


### res/menu配下にあるようなIcon(DrawerMenu等)のダークモード対応方法

以下にあるようにapp:itemIconTintとapp:itemTextColorで設定可能です
```xml
<com.google.android.material.navigation.NavigationView
  app:itemIconTint="@color/object_base_primary"
  app:itemTextColor="@color/text_base_primary"
  app:menu="@menu/drawer_menu".. />
```

### SearchViewに関してはActivity内で動的に色を指定してもOS13以降はView内の色が期待通りに変わらなかったため、[こちら](https://tech.uzabase.com/entry/2022/06/30/235212)の対処をしました

### ヘッダーを独自のCustomViewで定義している画面がそこそこ沢山あり、それらをすべてToolbarを用いた共通のXMLで置き換えました。

### 画面によっては同名XMLで410dp用等が存在しており、片方だけ変えても変更しませんでした。


## 終わりに
今回はAndroidにおける巨大プロジェクトのダークモード対応する際の見積もり手法について紹介しました。
記事では大変だった箇所ばかり紹介していますが、デザインチームの用意してくれたカラーパレットを含めたデザインシステムが本当に便利で開発効率がとても向上しました。
まだまだデザインシステム化が完全にできてはいないので道半ばですが、今後もっとデザインシステムとの連携がよいものになっていくのが楽しみです

NewsPicks ではエンジニアを募集中です！ご興味のある方は[こちら](https://hrmos.co/pages/uzabase/jobs/NP_Eng004)まで。


明日は[@nakamichi](https://qiita.com/nakamichi)さんが書いてくれます。お楽しみに！