post Image
iOSのソースをなるべくそのままAndroidに移植する方法

Kotlinを使いiOSのソースをなるべくそのまま流用してAndroidに移植する方法。

移植のための設計まわりの知見。
SwiftからKotlinへの移植のプログラミング的ノウハウはこちら

Activityは一つだけ

ActivityはMainActivity1つしか作らない。

Activityは画面というよりアプリケーションに近く以下のような性質を持つ。

  • データの受け渡し方法が特殊(Intent)
  • 起動モードを持つ(launchMode)
  • Stateの復元を考慮する必要がある(savedInstanceState)

これらの性質はUIViewControllerで作られたiOSの画面とマッチしない部分が多いので、Activityは画面ごとに作らずアプリケーションで一つだけにする。

大丈夫なの?

React NativeAdobe AIRといった、そこそこメジャーなフレームワークが1Activity構成になっているのでたぶん大丈夫。

後で知ったが、以下のような記事もみつけた。

「アプリにActivityはひとつでいい」という神のお告げ
→ 神は言っているらしい。

モダンなAndroid開発: アクティビティとフラグメントなんて捨ててしまおう
→ モダンらしい。

Activityとアプリケーションのライフサイクル

ここで注意するのは、アプリケーションとActivityがライフサイクルをともにすること。
具体的にはActivityの終了時にメモリに保持した値を破棄する

AndroidはメインActivityとJavaプロセスのライフサイクルが同期しないという厄介な仕様がある。
Activityが死んだがJavaのstaticフィールドが残ったり、逆にActivityが生きているのにJavaのstaticフィールドが消えたりする。
そのため、Activity終了時にメモリを破棄しないと、Activityの再起動時に前回起動時の情報が残った状態になってしまう。

メインActivityのonCreateで起動処理をし、onDestroyで全ての値をクリアすることで、アプリケーションとActivityがライフサイクルをともにするようにする。

MainActivity
class MainActivity : AppCompatActivity() {
    override fun onDestroy() {
        super.onDestroy()

        // Activityが死んでもJavaのプロセスは生きている場合があるので、
        // ここでcompanion objectやstaticフィールドに保持したものは全て破棄する
    }

}

Fragmentは使わない

Fragmentは色々癖が強く煩雑なので使わない。
iOSを模したUIViewController(後述)がFragmentに近い役割を担うことになる。

移植を抜きにしても、Fragmentは使わない方が楽だと思う。

UIViewControllerを作る

ここから具体的な実装に入るが、まずはiOSを真似てUIViewControllerを作る。

iOSではUIViewControllerとxibやstoryboardで画面を作成する。
Androidもそれに習い、UIViewControllerとxmlのレイアウトファイルで画面を作れるようにする。

UIViewController
open class UIViewController(layoutName: String? = null) {

    companion object {
        // 1. Context(Activity)はstaticに保持する
        private var activity: AppCompatActivity? = null
        fun setActivity(activity: AppCompatActivity?) {
           this.activity = activity
        }
    }

    protected val context: Context?
        get() = activity

    private var _view: ViewGroup? = null
    val view: ViewGroup // 3. viewはViewGroupにする
        get() {
            if (!_isViewLoaded) {
                _isViewLoaded = true
                // 4. viewDidLoadは最初にviewが参照されたとき呼び出す
                viewDidLoad()
            }
            return _view!!
        }

    private var _isViewLoaded = false
    val isViewLoaded: Boolean
        get() = _isViewLoaded

    // OnAttachStateChangeListenerでviewWillAppear、viewWillDisappear、viewDidDisappearを呼び出す
    private val attachListener: View.OnAttachStateChangeListener = object: View.OnAttachStateChangeListener {
        override fun onViewAttachedToWindow(view: View?) {
            viewWillAppear(false)
            view?.viewTreeObserver?.addOnGlobalLayoutListener(layoutListener)
        }
        override fun onViewDetachedFromWindow(view: View?) {
            viewWillDisappear(false)
            viewDidDisappear(false)
        }
    }

    // OnGlobalLayoutListenerでviewDidAppearを呼び出す
    private val layoutListener: ViewTreeObserver.OnGlobalLayoutListener = object: ViewTreeObserver.OnGlobalLayoutListener {
        override fun onGlobalLayout() {
            viewDidAppear(false)
            view.viewTreeObserver?.removeOnGlobalLayoutListener(this)
        }
    }

    private val layoutChangeListener: View.OnLayoutChangeListener = object : View.OnLayoutChangeListener {
        override fun onLayoutChange(v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
            viewDidLayoutSubviews()
        }
    }

    init {
        loadLayout(layoutName)
    }

    // 2. レイアウトファイルを読み込み、Viewを作成する
    private fun loadLayout(layoutName: String? = null) {

        val context = activity ?: return

        val layoutName = layoutName ?: javaClass.simpleName
        val resourceName = layoutName.toSnakeCase()
        val layoutId = context.resources.getIdentifier(resourceName, "layout", context.packageName)

        try {
            val loadedView = LayoutInflater.from(context).inflate(layoutId, null, false)
            _view = loadedView as ViewGroup
        } catch (e: Resources.NotFoundException) {
            Log.e(javaClass.simpleName, "Layout file not found!!! file name is [$resourceName]")
            throw e
        }

        val view = _view ?: return

        // 5. ButterKnifeでbindする
        ButterKnife.bind(this, view)

        view.addOnAttachStateChangeListener(attachListener)
        view.addOnLayoutChangeListener(layoutChangeListener)

        // 6. 裏のViewにタッチが貫通しないようにする
        view.setOnTouchListener { _, _ -> true }
    }

    open fun viewDidLoad() {}
    open fun viewWillAppear(animated: Boolean) {}
    open fun viewDidAppear(animated: Boolean) {}
    open fun viewWillDisappear(animated: Boolean) {}
    open fun viewDidDisappear(animated: Boolean) {}
}

いくつかポイントがあるので説明する。

1. Context(Activity)をstaticに保持する

Androidは画面と直接関係ない処理でも、いたるところでContextが必要になる。
これははっきり言って欠陥だと思うのだが、どうしようもないので companion object(Javaならstaticプロパティ)にActivityを保持するようにして、MainActivityのonCreate でセットする。

MainActivity
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        UIViewController.setActivity(this)
    }

    override fun onDestroy() {
        super.onDestroy()
        // onDestroyで必ずクリアする!!
        UIViewController.setActivity(null)
    }
}

しかし、Contextをstaticフィールドに保持するのは非推奨でいくつかの問題があるため、外からstaticに参照されないようprivateにしておくのが無難。
また、ActivityのonDestroyでstaticフィールドをクリアすることも忘れないように。

とはいえ、UIViewController内でcontextは必要になるので、protectedのプロパティでcontextを用意している。

2. レイアウトファイルを読み込み、Viewを作成する

レイアウトのxmlファイルを読み込んでViewを作成する。
iOSのUIViewControllerはデフォルトでクラス名と同名の xib ファイルを読み込むので、同じようにクラス名からレイアウトファイルを読み込むようにする。

Androidのレイアウトファイル名は大文字を使うことができないので、この例ではクラス名をスネークケースに変換している。
SubViewControllerというクラスの場合、sub_view_controller.xml というレイアウトファイルが読み込まれる。

3. viewはViewGroupにする

iOSのUIViewControllerが持つviewプロパティはUIView型だが、UIViewクラスを再現するのは大変なのでAndroidのViewGroupで代用する。
ViewでなくViewGroupを使うのは、Viewだとsubviewを追加することができないため。

4. viewDidLoadは最初にviewが参照されたとき呼び出す

viewDidLoadはviewを作成した直後ではなく、初めてviewが参照されたときに実行する。viewのgetterで実行という特殊な実装になっているが、これには2つ理由がある。

一つはiOSのUIViewControllerが初めてviewが参照されたときにviewDidLoadが実行される仕様になっているため。
(レイアウトファイルの読み込みもそのタイミングかもしれない)

もう一つは上記のloadLayoutメソッド内でviewDidLoadを呼び出すと、プロパティがまだ初期化されていない状態でviewDidLoadが実行されてしまう問題があるため。

5. ButterKnifeでbindする

IBOutletとIBActionを真似るためButterKnifeを使っているが、こちらについては後述する。

6. 裏のViewにタッチが貫通しないようにする

AndroidのViewはそのままだと裏のViewにタッチイベントを伝えてしまうので、OnTouchListenerでtrueを返して裏のViewにタッチイベントが発生しないようにする。

その他

viewWillAppear、viewWillDisappear、viewDidDisappear、viewDidAppearを呼び出すため、OnAttachStateChangeListenerとOnGlobalLayoutListenerを使っているが、これらのイベントはあまり使っていないため、しっかり動作検証していない。
何か問題があれば教えてほしい。

おまけ

キャメルケースをスネークケースに変換するExtention。クラス名からレイアウトファイル名に変換するのに使った。

toSnakeCase()
fun String.toSnakeCase(): String {
    var canGoNextBlock = false
    return map {
        val separator = if (it.isUpperCase() && canGoNextBlock) "_" else ""
        canGoNextBlock = it.isLowerCase()
        separator + it.toLowerCase()
    }.joinToString("")
}

iOSのデータ型を作成

UIColor、CGRectなどiOSでよく使うデータ型は真似て作っておくと便利。
これらは作れば作るほど移植が楽になるし、別のプロジェクトでも使いまわすことができる。

逆に作らなければ移植できなかというとそんなこともないので、時間とやる気のある範囲で作り込む。

以下はiOSを真似て作ったUIColorの例。
Androidは色をIntで扱うので、iOSにはないintValueのプロパティを追加している。

UIColor
class UIColor {

    companion object {
        // 実際は他にもたくさん色定数がある
        val green = UIColor(rgbValue = 0x00FF00)
    }

    private var argbInt = 0xFFFFFFFF.toInt()

    // Androidで使いやすいようIntの値を取得できるようにする
    val intValue: Int
        get() = argbInt

    constructor(argbInt: Int) {
        this.argbInt = argbInt
    }

    constructor(rgbValue: Int, alpha: Double = 1.0) {
        var iAlpha = to255(alpha)   // 0x 00 00 00 FF
        iAlpha = iAlpha shl (8 * 3) // 0x FF 00 00 00
        argbInt = iAlpha or rgbValue
    }

    constructor(rgbValue: Long, alpha: Double = 1.0) : this(rgbValue.toInt(), alpha)

    constructor(white: Double = 1.0, alpha: Double = 1.0) {
        val iWhite = to255(white)
        argbInt = Color.argb(to255(alpha), iWhite, iWhite, iWhite)
    }

    constructor(red: Double, green: Double, blue: Double, alpha: Double) {
        argbInt = Color.argb(to255(alpha), to255(red), to255(green), to255(blue))
    }

    private fun to255(d: Double): Int {
        return (d * 255.0).toInt()
    }
}

iOSのUIクラスを作成

UILabel、UIButton、UIImageViewなどiOSのUIコンポーネントは、AndroidのUIコンポーネントを継承して作る。

UILabel
class UILabel: AppCompatTextView {

    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
}
UIButton
class UIButton: AppCompatButton {

    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
}

上記の実装をベースに、iOSと同名のプロパティやメソッドを必要なだけ追加していく。
データ型同様どこまで作り込むかは融通が効くので、これもまた時間とやる気のある範囲で作り込む。

もしiOSアプリから新規に設計できるなら、UIButton、UILabelなどはそのまま使わず、継承したカスタムクラスでラップして使うとよりAndroidとの差が吸収しやすくなる。

データ型をTypeAliasで似せる

CGFloatなどiOSの基本的なデータ型は、typealiasで適当な型に置き換えると便利。

typealias CGFloat = Double
typealias TimeInterval = Double

ExtentionでiOSを真似る

一から作るのが大変なiOSのクラスは、似ているAndroidの標準クラスをExtentionで拡張してiOSを真似る。
iOSのUIViewは一から作るのが大変なので、AndroidのViewを拡張することでUIViewに近い取り回しができるようにする。

var View.isHidden: Boolean
    get() = visibility == View.INVISIBLE || visibility == View.GONE
    set(value) {
        visibility = if (value) View.INVISIBLE else View.VISIBLE
    }

var View.backgroundColor: UIColor?
    get() {
        val drawable = background as? ColorDrawable
        if (drawable != null) {
            return UIColor(argbInt = drawable.color)
        }
        return  UIColor.clear
    }
    set(value) {
        setBackgroundColor(value?.intValue ?: android.R.color.transparent)
    }

fun View.removeFromSuperview() {
    (parent as? ViewGroup)?.removeView(this)

}

fun ViewGroup.addSubview(view: View) {
    addView(view)
}

String、MutableList、Mapなど基本的なデータクラスも、Extentionを作ってiOSと同じメソッドやプロパティを作っておくと便利。

IBOutletとIBActionを真似る

IBOutletとIBActionを真似るためにちょっとしたチートを使う。

最初、リフレクションで@IBOutlet@IBActionのついたプロパティをViewと結びつけるよう実装してみたが、あまりに遅くて使い物にならなかった

Annotation Processingならパフォーマンスに悪影響はないが、自分で作るのは結構面倒だ。
そこで思いついて試してみたら実際うまく行った方法が、ButterKnifeのアノテーションをtypealiasで改名する方法。

typealias IBOutlet = BindView
typealias IBAction = OnClick

以下のようにアノテーションの引数にViewのIDを指定して、レイアウトファイルのViewとプロパティ、ファンクションを紐付ける。


@IBOutlet(R.id.nameLabel) lateinit var nameLabel: UILabel

@IBAction(R.id.testButton) fun buttonAction() {}

ButterKnifeをKotlinで使うにはbuild.gradleに以下の設定を追加する必要がある。

apply plugin: 'kotlin-kapt'

dependencies {
    compile 'com.jakewharton:butterknife:8.8.1'
    kapt 'com.jakewharton:butterknife-compiler:8.8.1'
}

必要最低限で動作するbuild.gradleは以下のような形になる。

build.gradle
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'

android {
    compileSdkVersion 26
    defaultConfig {
        applicationId "jp.hogepiyo.iosconversion"
        minSdkVersion 19
        targetSdkVersion 26
        versionCode 1
        versionName "1.0"
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'com.android.support:appcompat-v7:26.1.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.0'

    compile 'com.jakewharton:butterknife:8.8.1'
    kapt 'com.jakewharton:butterknife-compiler:8.8.1'
}

Enumを移植する

EnumはSwiftとKotlinでそこそこ仕様が異なるため、全く同じように取り扱うことができない。
Kotlinでざっくりと、Swiftのenumの仕様を実現する。

rawValueの保持

SwiftのEnumはStringなどを継承してrawValueを持たせる事が多いため、同じようにrawValueを扱えるよう、rawValueの型ごとにStringEnum、IntEnumなどのインターフェースを定義する。

rawValue(String)のインターフェース
interface StringEnum {
    val rawValue: String
}

上記のインターフェースを使うと、Swift、Kotlinそれぞれでのenumの書き方は以下のようになる。

Swift
enum Status: String {
    case success  = "0"
    case warning  = "1"
    case error    = "2"
}
Kotlin
enum class Status(override val rawValue: String) : StringEnum {
    success  ("0"),
    warning  ("1"),
    error    ("2");
}

これでKotlinのenumでも Status.success.rawValue のような形でrawValueが扱えるようになる。

rawValueの型ごとにインターフェースを作るのは少し面倒に感じるが、SwiftのEnumがStringとInt以外を継承することはあまりないので、多くの場合StringEnumとIntEnumがあれば事足りると思う。

rawValueからenumの値を取得

iOSのようにコンストラクターにrawValueを指定してenumを取得できるのが理想だが、Kotlinのenumはコンストラクターでインスタンス化できないので、クラスオブジェクトから取得するようにした。

rawValue(String)から値の取得
inline fun <reified T> KClass<T>.value(rawValue: String?): T? where T : Enum<T>, T : StringEnum {
    return enumValues<T>().firstOrNull { it.rawValue == rawValue }
}

使い方を比較すると以下のようになる。

Swift
var enum = Status(rawValue: "0")
Kotlin
var enum = Status::class.value(rawValue: "0")

::classが冗長なのでもっと良い書き方があれば教えて欲しい。

Stringの場合、enum名をデフォルトのrawValueにする

SwiftではStringのenumはrawValueがデフォルトでenum名になるので、KotlinのenumにあるnameプロパティをrawValueとして利用する。

interface StringEnumDefault : StringEnum {
    val name: String
    override val rawValue: String
        get() = name
}

Swift、Kotlinそれぞれでの使い方は以下のようになる。

Swift
enum Status: String {
    case success
    case warning
    case error
}

print(Status.success.rawValue) // > "success"
Kotlin
enum class Status: StringEnumDefault {
    success,
    warning,
    error;
}

print(Status.success.rawValue) // > "success"

Intの場合、並び順をデフォルトのrawValueにする

KotlinのenumにあるordinalプロパティをrawValueとして利用する。

interface IntEnumDefault : IntEnum {
    val ordinal: Int
    override val rawValue: Int
        get() = ordinal
}

didSet

Kotlinのプロパティのセッターを使って、didSetのタイミングで処理を行うこともできるが、デリゲートを使うともっとSwiftのdidSetと似せることができる。

didSet
class DidSet<T>(private var value: T) {

    private var didSetProcess: (()->Unit)? = null

    constructor(value: T, didSetProcess: ()->Unit): this(value) {
        this.didSetProcess = didSetProcess
    }

    operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
        return value
    }

    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
        this.value = value
        didSetProcess?.invoke()
    }
}

fun <T> didSet(initial: T, didSetProcess: ()->Unit): DidSet<T> {
    return DidSet(value = initial, didSetProcess = didSetProcess)
}
Swift
var i: Int = 0 {
    didSet {
       print(i)
    }
}
Kotlin
var i: Int by didSet(0) {
    print(i)
}

上記の形だと、oldValueを参照できないが、少し改造すればoldValueも使える。

アプリケーションサンプル

今まで説明したUIViewControllerなどを使い、実際に動く簡単なアプリケーションの例がこちら。

MainActivity
class MainActivity : AppCompatActivity() {

    companion object {
        private var sharedInstance: MainActivity? = null
        val instance: MainActivity?
            get() = sharedInstance
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        sharedInstance = this

        UIViewController.setActivity(this)

        val rootView = findViewById<FrameLayout>(R.id.rootView)

        val controller = SampleViewController()
        rootView.addSubview(controller.view)
    }

    override fun onDestroy() {
        super.onDestroy()

        UIViewController.setActivity(null)
    }
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    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"
    tools:context="jp.yamamoto.iosconversion.MainActivity"
    android:id="@+id/rootView">

</FrameLayout>
SampleViewController.kt
class SampleViewController: UIViewController() {

    @IBOutlet(R.id.testLabel) lateinit var testLabel: UILabel
    @IBOutlet(R.id.testButton) lateinit var testButton: UIButton

    var count = 0

    override fun viewDidLoad() {
        super.viewDidLoad()

        testLabel.text = "Hello World !!!"
        testButton.backgroundColor = UIColor(red = 1.0, green = 0.5, blue = 0.5)
    }

    @IBAction(R.id.testButton) fun buttonAction() {
        count += 1
        testLabel.text = "Count $count"
    }
}
sample_view_controller.xml
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    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"
    tools:context="jp.yamamoto.iosconversion.MainActivity">

    <corelib.ios.UILabel
        android:id="@+id/testLabel"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="initial text"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <corelib.ios.UIButton
        android:id="@+id/testButton"
        android:layout_width="100dp"
        android:layout_height="50dp"
        android:text="button"
        app:layout_constraintTop_toBottomOf="@+id/testLabel" />

</android.support.constraint.ConstraintLayout>

どうしても吸収できなかった点

Swiftのstruct

今のところ最大の問題。
Swiftのstructは値渡しだが、これをKotlinで真似ることができなかった。
そのまま移植すると、値渡しと参照渡しの差で挙動が変わってしまうので、Kotlin側では明示的にコピーしてやる必要がある。

Dictionary、CGPointなど基本的なデータ型にもstructは多いので何とかしたいが、「structを使う場合は気をつける」というくらいしか対抗策を出せていない。

Swiftのセッターはオブジェクトの変更にも反応する

Swiftのセッターは値の代入だけでなく、オブジェクトの変更(mutating func)にも反応する。

Swift
var _list = [String]()

var list: [String] {
   get {return _list}
   set(value) {
      _list = value
      print("セッター呼ばれた")
   }
}

func append(item: String) {
   list.append(item) // これでセッターが呼ばれる!!!!
}

これはちょっとだけ便利な仕様だと思うが、Kotlinにはそんな機能はないのでセッターを呼ぶには代入が必要になる。

Kotlin
var _list = mutableListOf<String>()

var list: MutableList<String>
   get() = _list
   set(value) { 
      _list = value
      print("セッター呼ばれた")
   }

fun append(item: String) {
   list.append(item) // これではセッターは呼ばれない
   list = list // 代入するとセッターが呼ばれる
}

『 Swift 』Article List