FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

ScalaでAndroid開発をする方法 / セプテーニ技術読本(2017) PDF配布

こんにちは!杉谷と申します。 セプテーニグループのセプテーニ・オリジナル社とコミックスマート社のCTOを務めています。

AndroidがKotlinを正式サポートというニュースが駆け巡っています。

同じJVM系のScalaはどうなの?Androidで使えるの?と思われた方もいらっしゃると思いますが、 実は GANMA!Android版はScalaで作られています。 GANMA!はダウンロードも600万を超え、Google Play上でも高評価を頂戴しておりますので開発実績としては十分な物かと思われます。

本エントリではScala Matsuri 2017Scala将軍達の後の祭り2017で配布したプライズ同人誌 「セプテーニ技術読本(2017)」のPDFと、その中で掲載されたScalaAndroid開発」をお送りします。


本稿は2017年2月に配布されたプライズ同人誌 《セプテーニ 技術読本(2017)》 に収録されたものです。PDFはエントリの最後からダウンロードできます。

ScalaAndroidアプリ開発

はじめに

GANMA!のAndroid版はScalaを使って開発をしています。

皆様がまず気になるのは「ぶっちゃけ(Scalaで作って)どう?」だとおもいますが、 結論からいうと「動くし快適だが、途中が険しくてオススメしづらい」といった代物です。

GANMMA!のダウンロード数は400万を超え(※2017.2現在 / 2017.5時点では600万 )、アクティブユーザーもかなりの人数となっています。 安定性も高い状態を保てており、今のところGoogle Play上でも高評価を得られています。 Scalaでの開発実績としては十分なものかと思われます。

本稿では、ScalaAndroid開発を行ってみたい方のお役に立てるよう、 Android Studioを使った開発環境の構築方法をステップバイステップでご紹介いたします。

なぜScalaを使うのか?

繰り返しになりますが、ScalaでのAndroid開発は導入は面倒だし、将来性には難があります。 それでもその辛みを補って余りある嬉しさがあります。

  • 楽に書ける Scalaの豊富な記述性が醸し出す書き味は大変に素晴らしい物です。 標準CollectionAPIの充実、traitによる型表現の広がり、Scalaエコシステムの様々なライブラリ、どの要素も日々の開発を楽にしてくれます。
  • Future美味しい Android開発では通信をしてからUI更新、といった非同期処理をよく書きますが、 ScalaのFutureは快適に非同期処理が記述できます。Java/Kotlinでもライブラリを使えばFuture/Promiseは扱えますが、Scalaでの書きやすさにはかなわない印象です。Akkaも利用すれば、非同期処理を扱う手段はさらに広がるでしょう。

我々の場合、サーバはScalaで開発しているのと、1エンジニアがサーバもクライアントも区別無くいじる文化なので クライアント側もScalaにしておきたかった、という都合からScalaでのAndroid開発を始めたのですが、とても楽に開発できています。

Scalaと比較するならKotlinでしょうか? 実際の所Kotlinは深くは触っていないため、比較はできないのですが Kotlin公式サイトによる "Scalaとの比較"には

The main goal of the Kotlin team is to create a pragmatic and productive programming language, rather than to advance the state of the art in programming language research. Taking this into account, if you are happy with Scala, you most likely do not need Kotlin. (訳:Kotlinチームの主な目標は、プログラミング言語の研究における最先端技術を進歩させるのではなく、実用的で生産的なプログラミング言語を作成することです。これを考慮すると、あなたがScalaに満足しているのであれば、Kotlinはおそらく必要ないでしょう。)

と書かれています。(2017.5追記 比較ページは消された様子、 https://github.com/JetBrains/kotlin-web-site/blob/7e8e56a7a40f37b37e2e66c77fbbdf5484927b27/pages/docs/reference/comparison-to-scala.md に残っています)

Javaから移行する場合、Scalaが未経験であればKotlinでも十分な幸福さを得られると思いますが、 Scalaに慣れている場合は、導入の面倒さや将来性の不安を押してでもScalaで開発をする価値があるでしょう。

sbt-androidとgradle-android-scala-pluginの比較

Androidの標準ビルドシステムはGradleですが、Scalaでよく使われるビルドシステムはsbtです。 ScalaAndroid開発を行うアプローチは

の二つが主な方法となります。

sbt-androidは開発が活発で動作が安定していますし、ドキュメントも充実しています。Instant Runも利用できるようです。

ただし、ビルドシステムがAndroid標準ではないので、プロジェクトをAndroid StudioやIDEAで扱うには一手間あるほか(実行設定をsbtを通した物にする必要がある等) テストやデバッガのIDE連携が出来ない1、などAndroid統合機能との連携に難があります。

gradle-android-scala-pluginはIDEAやAndroidStudioで特別な設定無しに扱えるのが大きなメリットです。 プロジェクトをインポートするだけで全てのAndroid統合機能が利用できます。デバッガやInstantRunも通常通り使えます。 特に動かしたいユニットテストGUIから選んで単独実行できる点が大変に便利です。

ただし開発が活発ではない、というか止まっているという強烈な問題があります。

MavenのCentral Repositoryに登録されているバージョンでは 新しいGradleが利用できず、最新のAndroid Build Toolも使えない、ビルドも遅い、という問題があるため、 対応が進んでいるmasterブランチのHEADを取得し自力でビルドを行う必要があります2Android界の進展は早いので、将来にわたって利用できるかどうかが懸念となります。

我々はそれでも使い勝手が良く、テストを実行しやすいgradle-android-scala-pluginのほうを利用しています。

プロジェクト作成

それでは実際にAndroid Studio 2.2.3を使って簡単なAndroidアプリを作れるようになるまで、 gradle-android-scala-pluginのビルドからステップバイステップで紹介いたします。

gradle-android-scala-pluginのビルド

最初にGradleのインストールします。 インストールにはJVM系のインストールマネージャであるSDKMANを利用します。

以下のコマンドでインストールされます。

$ curl -s get.sdkman.io | bash

次のコマンドを実行し初期設定を行います。

$ source "$HOME/.sdkman/bin/sdkman-init.sh"

次にgradleをインストールしますが gradle-android-scala-pluginは2017年の1月のmaster:HEADでは2.14.0,2.14.1ではビルドできなかったので、 対応とされている2.12をインストールします。3

$ sdk install gradle 2.12

gradle-android-scala-pluginをcloneします。

$ git clone https://github.com/saturday06/gradle-android-scala-plugin.git

ビルドします。

$ cd gradle-android-scala-plugin/
$ gradle assemble

./build/libs/gradle-android-scala-plugin-1.5-SNAPSHOT.jarプラグインのパッケージが作成されます。 後ほどAndroidプロジェクト作成時に、このJARを利用します。

Android Studioのセットアップとプロジェクト作成

Android Studioをインストールし実行します。 初回起動時にインストール方法(Install Type)を聞かれますが、Standardで進めます。

Welcome to Android Studioの画面が表示されたら、メニューの Configure > Plugins を選び Install JetBrains plugin画面から、Scalaプラグインをインストールします。

インストールが終わったら、 Android Studioを再起動し、再びWelcome画面から Start a new Android Studio projectを選び新規プロジェクトを作成します。 プロジェクト名はお好みで大丈夫です。本稿ではScalaHelloWorldという名前で~/AndroidStudioProjects/ScalaHelloWorld/に作成するとします。企業ドメインsepteni-original.co.jp(=Package Nameがjp.co.septeni_original)とします。

Minimum SDKScala自体はAndroid 2.x系が対象でも利用できなくはないですが、苦痛がとても大きいので4.0.3以上を推奨します。

Add an Activity to MobileEmpty Activity を選択します。

Customize the Activityは初期値のままで進めます。

これで~/AndroidStudioProjects/ScalaHelloWorld/にプロジェクトが作成されました。

プロジェクトにgradle-android-scala-pluginを導入

プロジェクトのディレクトリ(~/AndroidStudioProjects/ScalaHelloWorld/)に 先ほど作成したgradle-android-scala-plugin-1.5-SNAPSHOT.jarをコピーしbuild.gradleで利用指定をします。(以降、ファイル名を表すとき ~/AndroidStudioProjects/ScalaHelloWorld/を省略)

build.gradle

buildscript {
    …
    dependencies {
        classpath 'com.android.tools.build:gradle:2.2.3'
        classpath files("gradle-android-scala-plugin-1.5-SNAPSHOT.jar")
        …
    }
    …
}

次にapp/build.gradleプラグイン利用指定を行います。

…
apply plugin: 'com.android.application'
apply plugin: "jp.leafytree.android-scala"

依存ライブラリにScala 2.11.8を追加します。4

app/build.gradle

dependencies {
    …
    compile 'org.scala-lang:scala-library:2.11.8'
    …
}

これで最低限のプロジェクト設定はできました。

HelloWorld

Android Studioによって作られたMainActivity.javaScalaで書き直してみましょう。

app/src/main以下にディレクトscalaを作成します。

app/src/main/scala以下にパッケージjp.co.septeni_original.scalahelloworldを作成(=jp/co/septeni_original/ディレクトリを作成)します。

app/src/main/scala/jp/co/septeni_original/MainActiviy.scalaを作成します。

f:id:ysugitani:20170519103826p:plain

MainActiviy.scalaの編集画面上部にNo Scala SDK in moduleと出てくるので、Setup Scala SDKを押下し、Scala 2.11.8を追加します。

MainActivity.scalaMainActivity.javaと同等の記述をします。

MainActivity.scala

package jp.co.septeni_original.scalahelloworld

import android.os.Bundle
import android.support.v7.app.AppCompatActivity

class MainActivity extends AppCompatActivity {
  
  override def onCreate(savedInstanceState: Bundle): Unit = {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
  }

}

Java側(app/src/main/java以下)にあるMainActiviy.javaを削除します。

これで実装は完了です。

メニューのRun > Run app、もしくはメニューバーのRunボタンを押し、 Android5.0(API Level 20)以上5エミュレーターを作成し、実行します。

f:id:ysugitani:20170519103846p:plain

デバッグ実行を行えばデバッガも利用できます。

テスト

次に、MainActivityと同じように、Android Studioにより作成された実機で実行する単体テスト(ExampleInstrumentedTest.java)のほうもScalaで書き直してみましょう。残念ながらspecs2等は使えずJunit4となります。

app/src/androidTest以下にディレクトscalaを作成します。

app/src/androidTest/scala以下にパッケージjp.co.septeni_original.scalahelloworldを作成します。

app/src/androidTest/scala/jp/co/septeni_original/ExampleInstrumentedTest.scalaを作成します。

f:id:ysugitani:20170519103901p:plain

ExampleInstrumentedTest.scalaExampleInstrumentedTest.javaと同等の記述をします。

ExampleInstrumentedTest.scala

package jp.co.septeni_original.scalahelloworld

import android.content.Context
import android.support.test.InstrumentationRegistry
import android.support.test.runner.AndroidJUnit4
import org.junit.Assert._
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(classOf[AndroidJUnit4])
class ExampleInstrumentedTest {

  @Test
  def AppContextを使える(): Unit = {
    val appContext: Context = InstrumentationRegistry.getTargetContext
    assertEquals("jp.co.septeni_original.scalahelloworld", appContext.getPackageName)
  }
}

Java側(app/src/androidTest/java以下)にあるExampleInstrumentedTest.javaを削除します。

プロジェクトナビゲーター側からExampleInstrumentedTestを右クリックし、 Run 'ExampleInstrumentedTest'からテストを実行します。

f:id:ysugitani:20170519103917p:plain

MultiDex

次にAPI Level 20未満でも実行できるようにMultiDex6の設定を行います。

app/build.gradle に以下の設定を追記します。

android {
    defaultConfig {
      …
        multiDexEnabled true
      …
    }
}
dependencies {
    …
    compile "com.android.support:multidex:1.0.1"
    …
}

MultiDex処理を実行するApplicationクラスを新規作成します。7

app/src/main/scala/jp/co/septeni_original/scalahelloworld/ScalaHelloWorldApplication.scala

package jp.co.septeni_original.scalahelloworld

import android.app.Application
import android.content.Context
import android.support.multidex.MultiDex

class ScalaHelloWorldApplication extends Application {
  override protected def attachBaseContext(base: Context): Unit = {
    super.attachBaseContext(base)
    MultiDex.install(this)
  }
}

このApplicationクラスが実行されるようにManifestに追記します。

app/src/main/AndroidManifest.xml

<application
        android:name=".ScalaHelloWorldApplication"

一番最初のdexファイル(classes.dex)にはScalaHelloWorldApplicationクラス自身と、 ScalaHelloWorldApplicationクラスが利用するクラスが含まれる必要があります。

次にこれを指定するファイルをapp/multidex.keepに記述、設定します。

android/support/multidex/BuildConfig.class
android/support/multidex/MultiDex$V14.class
android/support/multidex/MultiDex$V19.class
android/support/multidex/MultiDex$V4.class
android/support/multidex/MultiDex.class
android/support/multidex/MultiDexApplication.class
android/support/multidex/MultiDexExtractor$1.class
android/support/multidex/MultiDexExtractor.class
android/support/multidex/ZipUtil$CentralDirectory.class
android/support/multidex/ZipUtil.class
jp/co/septeni_original/scalahelloworld/ScalaHelloWorldApplication.class

以降、ScalaHelloWorldApplicationクラスが利用するクラスが増えたときはこのファイルに追記する必要があります8

multidex.keepが読み込まれるよう、build設定を変更します。

app/build.gradle

android {
    …
    dexOptions {
        additionalParameters = [
                '--multi-dex',
                "--main-dex-list=$projectDir/multidex.keep".toString(),
                '--set-max-idx-number=45000'
        ]
    }
    …
}

additionalParametersの--set-max-idx-number=45000--multi-dexは本来は不要なのですが、 Android4.0.x系では1dexに65535まで詰め込むと読み込めない事があるので、4.0.x系を動作対象に入れる場合は--set-max-idx-number=45000(1dex45000までに制限する)を指定したほうが安全です。 また我々の環境では--multi-dexも設定しておかないとInstant Runが正しく動作しなかったので指定しています。

以上でMultiDexの設定は完了です。API Level 15あたりのエミュレーターを作成し、動作確認を行いましょう9

独自Applicationクラスの注意点

gradle-android-scala-pluginや元々のMultiDexのREADMEでは、独自のApplicationクラスを作る場合は以下に注意するよう案内されています。

  • static field(Scalaであればclassやobjectのコンストラクタ)はMultiDex#installより先に呼ばれるので最初のclasses.dexに含まれる(multidex.keepに記述する)ようにしましょう。
  • Applicationクラスのメソッドが実行されるとき、Applicationクラスより後に読まれる他のクラスのへのアクセス権がない可能性があります。以下のようにRunnableなど、別のクラスを噛ませると問題を回避できます。
  override def onCreate = {
    super.onCreate

    val context = this
    new Runnable {
      override def run = {
        // 独自の処理をここに記述。
        // thisの代わりにcontextを使うこと
      }
    }.run
  }

この場合、multidex.keepにいちいち利用するクラスを記述する必要がありません。

通しで記述すると以下のようになります。

app/src/main/scala/jp/co/septeni_original/scalahelloworld/ScalaHelloWorldApplication.scala

package jp.co.septeni_original.scalahelloworld

import android.app.Application
import android.content.Context
import android.support.multidex.MultiDex

class ScalaHelloWorldApplication extends Application {

  override def onCreate(): Unit = {

    super.onCreate()

    val context = this
    new Runnable {
      override def run(): Unit = {
        // 独自の処理をここに記述。
        // thisの代わりにcontextを使うこと
      }
    }.run()
  }

  override protected def attachBaseContext(base: Context): Unit = {
    super.attachBaseContext(base)
    MultiDex.install(this)
  }

}

Futureを利用する

ScalaAndroidを開発する大きな理由の一つにFutureとPromiseがあります。 もちろんJava/Kotlinでもライブラリを使うなり作るなりすれば可能ですが、Scalaでの書き味の良さには及ばない印象があります。

通信などでFutureを利用する場合、大体の場合「通信をするFutureを作る→.andThenや.onCompleteでUIを更新する」 といった処理になるのですが、AndroidではUI操作はUIスレッドで行う必要があるので、 最後のUI更新はUIスレッドで行う必要があります。

この処理をExecutionContextでラップし使い勝手を良くするgistを ScalaMatsuri座長の麻植さん(@OE_uia)が公開されています。

これを利用すると、FutureコールバックはUIスレッドで実行する処理は、以下のように記述できます。

val tv = new TextView(this)
val x: Future[Long] = future { … }
…
val mine = new UIExecutionContext(this)
x.onComplete { // onSuccessはScala2.12からdeprecatedなのでご注意
  case Success(count) => tv.setText(tv.getText + " onSuccess!:"+count.toString )
  case Failure(t) => …
}(mine)

また、場合によっては、次のようなサポートクラスを利用しています。

object ThreadUtil {

  def runOnUiThread(exec: => Unit): Future[Unit] = {
    val result = Promise[Unit]()

    def execute(): Unit = {
      Try {
        exec
      }.map { _ =>
        result.success(Unit)
      }.recover {
        case e: Throwable => result.failure(e)
      }
    }

    if (Looper.myLooper() == Looper.getMainLooper) {
      execute()
    } else {
      val handler = new Handler(Looper.getMainLooper)
      handler.post(new Runnable {
        override def run(): Unit = execute()
      })
    }

    result.future
  }
}

次のように利用します。

ThreadUtil.runOnUiThread(tv.setText("ほげ"))

付録: ProGuardについて

アプリをリリースするときProGuardをかける場合があります。

設定方法は通常と変わりませんが、Scalaのライブラリを多数除外指定する必要があります。

pfn氏作の sbt-androidのほうで容易されている android-proguard.config をたたき台に調整していくと便利です。

付録: dexに格納されているクラス一覧を確認する方法

apkファイルをunzipし、出てきたdexファイルに対してdexdumpをする事によって確認できます。

$ cd app/build/outputs/apk
$ unzip app-debug.apk
$ dexdump *.dex | grep 'Class descriptor'
  Class descriptor  : 'Landroid/support/annotation/AnimRes;'
  Class descriptor  : 'Landroid/support/annotation/AnimatorRes;'
  Class descriptor  : 'Landroid/support/annotation/AnyRes;'
  Class descriptor  : 'Landroid/support/annotation/AnyThread;'
  Class descriptor  : 'Landroid/support/annotation/ArrayRes;'
  Class descriptor  : 'Landroid/support/annotation/AttrRes;'
  Class descriptor  : 'Landroid/support/annotation/BinderThread;'
  Class descriptor  : 'Landroid/support/annotation/BoolRes;'
  …

終わりに

如何でしたでしょうか?

ご紹介させていただいたとおり、使えるようになるまでがの道が険しく、将来の不安も残る方法です。大体の場合はKotlinを選ぶ方が良いでしょう。

しかしそれでもScalaを使う価値はあります。 本稿がScala愛のある方のお役に立てると幸いです。

《杉谷保幸》


セプテーニ技術読本 2017

f:id:ysugitani:20170519102932j:plain:w300

ダウンロードはこちら


  1. 我々が動かす方法が見つけられなかっただけなので、方法はあるのかもしれません。

  2. メンテナとして名乗り出ようかな、と考えています。

  3. この場面以外なら新しいバージョンのGradleを利用できます。動作速度が向上しているのでより新しいバージョンを使った方が良いでしょう。

  4. Android SDKは現時点ではJava7バイトコードまでしか対応していないため、Scala2.12はまだ利用できません。

  5. Scalaを導入するだけでクラス数は1dex上限の65535を超えてしまうので、Android5.0(API Level 20)未満ではまだ動かせません。後述するMultiDex設定が終わるまではAndroid5.0以上を利用します。

  6. MultiDexに関する詳細説明は割愛します。

  7. 実際にはここまで単機能であれば、android.support.multidex.MultiDexApplicationを使うか継承するで十分なのですが、後ほど拡張するので本稿では自分で定義をしました。

  8. 追記する場合、ビルド時に生成されるbuild/intermediates/multi-dex/debug/multidexlist.txtを参照すると探しやすいです。

  9. 最初のdexに格納されているclassを確認したい場合、後述の付録: dexに格納されているクラス一覧を確認する方法をご参照ください。