この記事は CrowdWorks Advent Calendar 2017 の2日目の記事です。
CrowdWorksで公式Androidアプリの開発をやっている @YusukeIwaki がお送りします。


まえおき

いまさらですが、Androidアプリって開発するの難しいですよね。何も考えずに機能を追加していくと、すぐにFatなActivity/Fragmentができてしまいます。

「じゃあFat Activity/Fragmentを回避するために、MVPアーキテクチャーを導入しよう!」ってなると今度は、ボイラープレートコードが増えます。

「こんな無駄なコードを書きたくない!!」という衝動が強くなり、カッとなってオレサマコードジェネレータが作られたり、オレサマアーキテクチャが生まれたりします。

「うーん、本質的に解決になってるのかこれ?」とある日考えます。

・・・と、(やや極端ですが)アプリエンジニアであればこんな経験を一度や二度はしたことがある人も多いのではないでしょうか。

そもそもFatなActivity/FragmentはどうしてFatになるか?

FatなActivity/Fragmentというのは、「本来Activity/Fragmentに書かなくてもいいことをActivity/Fragmentに書いてしまってる」のチリツモで起きます。

仮にAPI通信とかの処理をActivity/Fragmentに書いているのだとしたら、イケてるアーキテクチャを導入することで解決ができるでしょう。でも、そもそもAPI通信とかって真っ先に画面から切り出すので、あんまりActivity/Fragmentには書きませんよね?

Fatになる原因は、たとえば

@Override
protected void onCreate(Bundle savedInstanceState) {
  TextView titleTextView = findViewById(R.id.title);
  setupTitleView(titleView);

  TextView bodyTextView = findViewById(R.id.body);
  MarkdownHandler markdownHandler = new MarkdownHandler(this);
  markdownHandler.initializeWith(bodyTextView);
  markdownHandler.setImageLoader(new MarkdownImageLoader(this));

こんな感じの、画面のセットアップ処理が意外と多いのではないでしょうか?

画面のUI部品のセットアップ処理というのは画面に依存しているので切り出しにくいし、再利用するにもコピペするしかない みたいな状態になりがちです。

イケてるアーキテクチャを採用するよりも先にコンポーネント化をしよう

UI部品が多くてセットアップ処理が肥大化してしまう、というのは、多くの場合、カスタムビューを作ってコンポーネント化することで解決できます。
画面のセットアップ処理はActivity/Fragmentにしてもらうのをやめて、あらかじめセットアップされた状態のUI部品を置こう!という発想です。

たとえばこんな要件だったらどうしますか?

要件
紫枠部分 FontAwesomeで特定のアイコン文字を表示する
赤枠部分 [0,0] のときは「予算は相談」、 [0, 12345] のときは「〜12345円」みたいな表記、[123, 456] のときは「123〜456円」みたいな表記
緑枠部分 1時間以内は「○分前」、36時間以内は「○時間前」、1ヶ月以内は「○日前」、それより前は「○ヶ月前」

※ 弊社のアプリの画面で説明してますが実際の仕様とは微妙に異なります(^^;;)

この程度の要件で、いきなりカスタムビューを使うという人はおそらくいないでしょう。
たとえば緑枠部分であれば、おそらく多くの人は、以下のようにTimestampManagerみたいな補助クラスを作ってTextViewにそのテキストを入れる、という方法をとる方が多いのではないかと思います。

class TimestampManager {
  private final currentTimeMs;

  public TimestampManager(long currentTimeMs) {
    this.currentTimeMs = currentTimeMs;
  }

  public String getTextFor(long timestampMs) {
    if (timestampMs > currentTimeMs) {
      setText("未来の時刻");
      return;
    }
    if (timestampMs > currentTimeMs - 60000) {
      setText("たった今");
      return;
    }
    if (timestampMs > currentTimeMs - 3600000) {
      setText(String.format("%s分前", (currentTimeMs - timestampMs)/60));
      return;
    }

    ... //以下、同様に…
  }
}

TimestampManager timestampManager = new TimestampManager(TimeUtils.getCurrentTimeMs());

TextView timestampText = findViewById(R.id.timestamp);
String timestampString = timestampManager.getTextFor(jobOffer.getReleasedAt());
timestampText.setText(timestampString);

これ単体だと、例が簡単すぎて、「これでもいいじゃないか?」と思うかもしれません。でも、仮に同じような時刻表現をする画面がいくつもある場合にはどうでしょう?
↑の4行は毎度コピペしないといけなくなりますよね。

そこで、カッとなってカスタムビュー化してみます。

package jp.co.crowdworks.androidapp.widget;

public TimestampTextView extends AppCompatTextView {

  ... //コンストラクタは省略

  public void setTimestamp(long timestampMs) {
    long currentTimeMs = TimeUtils.getCurrentTimeMs();
    if (timestampMs > currentTimeMs) {
      setText("未来の時刻");
      return;
    }
    if (timestampMs > currentTimeMs - 60000) {
      setText("たった今");
      return;
    }
    if (timestampMs > currentTimeMs - 3600000) {
      setText(String.format("%s分前", (currentTimeMs - timestampMs)/60));
      return;
    }

    ... //以下、同様に…
  }


}

とりあえずTextViewを拡張した独自のカスタムビュークラスを作りました。


    <jp.co.crowdworks.androidapp.widget.TimestampTextView
          android:id="@+id/timestamp"
          android:layout_width="wrap_content"
          android:layout_height="wrap_content" />

レイアウトファイルでは従来のTextViewと同じように配置しました。

すると・・・

TimestampTextView timestampText = findViewById(R.id.timestamp);
timestampText.setTimestamp(jobOffer.getReleasedAt())

単純にタイムスタンプの情報を入れるだけになります!

補助クラスにあったロジックはすべてTimestampTextViewの中に閉じ込められており、なんとなくコードがきれいになりましたね。
なにより、別の画面で似たような時刻表現するときにもコピペする必要がありませんね!

ついでにライブラリプロジェクトに分離してしまおう

ライブラリ化するとbuild.gradleに1行書くだけでカスタムビューが使えるようになるので、「サンプルアプリでとりあえず動作を見て、OKだった本プロジェクトに反映しよう」ということができます。

アプリって開発しているとだんだんビルドに時間かかるようになりますよね。
「○時間前」の表記が正しいかを確認するためだけに毎度1〜2分とか待たされるの、いやですよね。
毎度重いビルドを走らせなくても、3秒くらいでビルドが完了するサンプルプロジェクトで思う存分デバッグができたらステキですよね。

ということで、カスタムビューをライブラリプロジェクトに分離します。

$ ls
app         build.gradle        gradle.properties   local.properties
build           gradle          gradlew         settings.gradle

$ mkdir -p widgets/src/main/
settings.gradle
include ':app'
include ':widgets' //追加
widgets/build.gradle
apply plugin: 'com.android.library'

android {
    compileSdkVersion 27
    buildToolsVersion '27.0.0'

    defaultConfig {
        minSdkVersion 15
        targetSdkVersion 27
    }
}

dependencies {
    provided 'com.android.support:appcompat-v7:27.0.1'
}
widgets/src/main/AndroidManifest.xml
<manifest package="jp.co.crowdworks.androidapp.widgets"/>

上記のようにいろいろ追記して、

ポチッとSync Nowすると・・

widgetライブラリプロジェクトができました。ここに、

$ mkdir -p widgets/src/main/java/jp/co/crowdworks/androidapp/widgets/
$ touch widgets/src/main/java/jp/co/crowdworks/androidapp/widgets/TimestampTextView.java

とやればTimestampTextViewの空ファイルができます。

あとは、先ほどまで appの方にあったTimestampTextViewをwidgetsのほうに移して、

app/build.gradle
// 前略...

dependencies {
    implementation project(':widgets') // ★追加
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'com.android.support:appcompat-v7:26.1.0'

// 後略...

app/build.gradleのほうに1行追加すれば完了です。
手順は長くなりましたが、これでappにあったTimestampTextViewを別プロジェクトに切り出せました。

CrowdWorksアプリの開発現場から生まれたカスタムビュー

普段のQiita記事だったらこんなこと書かないんですが、クラウドワークスアドベントカレンダーの一環で書いてる記事なので、少しだけ会社での取り組みも書いておきます。

みんな大好きFontAwesome・・・からのFontAwesomeTextView

Webのデザイナーさんがアプリのデザインを担当すると、わりと高頻度でFontAwesomeのアイコンが登場します。CrowdWorksアプリを作り始めた頃は、デザイナーさんにFontAwesomwをPNGアイコン化してもらって、アプリでアイコン画像を表示するようにしていました。が、

・アイコンが増えてかなりの容量を食うようになってきた
  ↓
・1年前くらいにTextViewでFontAwesomeのttfを設定して表示するように変えた
  ↓
・FontAwesomeHelperという補助クラスを使っていたけど、アイコンの数が増えてくるにつれて「なんでアイコンを表示するだけで毎回Javaコード書かなきゃいけないんだ!」ってなってきた。
  ↓
・Android StudioのXMLレイアウトのプレビューで確認ができないのがつらくなってきた。
・string.xmlに fa_ほげほげ を定義するのが面倒になってきた。
  ↓
・FontAwesomeTextViewにしちゃおう!!

という過程を経て、レイアウトXMLファイルで

<jp.co.crowdworks.android.widget.FontAwesomeTextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textSize="24dp"
    android:text="@string/fa_user" />    

って書くだけでFontAwesomeアイコンが表示されるTextViewつくりました。
https://github.com/crowdworks/android-widget#fontawesome

include @layout/divider_view から <DividerView>

Divider っていろんな所で使われてる割に、AndroidフレームワークやAndroidサポートライブラリではビューが提供されていません。

res/layout/divider_view.xml
<View
  android:layout_width="match_parent"
  android:layout_height="1dp"
  android:alpha="0.12"
  android:background="@android:color/black"/>

最初は↑のようなXMLをあちこちでincludeしていました。
しかし、カスタムビューに慣れてくると、「カスタムビュー化しちゃったほうがいろんなプロジェクトで使えて便利では?」と思うようになり、カスタムビュー化しました。
https://github.com/crowdworks/android-widget#divider

<jp.co.crowdworks.android.widget.DividerView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:thickness="2dp" />

作ったときには特に考えていなかったのですが、「キャンペーン画面だけはDividerの線の太さをちょっと太く&濃くしたい!」とかも楽々できるようになりました。

まとめの前に(カスタムビュー化して本当によかったこと)

アプリ開発は、趣味のアプリでないかぎり大抵の場合はデザイナーさんと協業しますよね。
デザイナーさんたちはアプリの実装のことを必ずしも知りません。Webでできることはアプリでもできて当然!iOSでできることはAndroidでも楽勝!って思っている場合がほとんどです。

  • 角がちょっと丸いボタン
  • 凝ったインジケーターの表示
  • 画像とテキストの両方があるボタン

デザイナーさんたちはこれらの部品が「当然使えるもの」とおもってデザインします。

アプリ開発者が「今回だけは特別に実装しよう・・・」って実装すると、デザイナーさんは「あ、実装できるんじゃん」となり、次のデザインでもこれらの部品が登場します。

ようするに、「角がちょっと丸いボタン」は「角がちょっと丸いボタン」として気軽に再利用やカスタマイズできるようにしておかないと、Javaコードのコピペが増えたり、XMLリソースがめちゃくちゃ増えたり、ってなっていくのです。

カスタムビューによるコンポーネント化をすることで、デザイナーさんの要望にも応えていきやすくなるのです。つまらないことでギクシャクせずにアプリ開発ができます。
これまじで重要です。

 

まとめ

カスタムビューによるコンポーネント化は、1つ1つはとても小さい内容です。でも、冒頭でも述べたとおり、こういう小さなことを積み上げていくことで、アプリ開発が"なんとなく"ラクになります。

リファクタリングの必要性を感じたときに、イケてるアーキテクチャの導入とかは結構な労力がかかりますが、カスタムビューによるコンポーネント化は少しずつ小さく始めていけるのでオススメです。
まだ検討されたことがない方は是非、試してみてはいかがでしょうか。