2019-05-21 enumの取得方法について追記
Kotlinを使いiOSのソースをなるべくそのまま流用してAndroidに移植する方法。
移植のための設計まわりの知見。
SwiftからKotlinへの移植のプログラミング的ノウハウはこちら。
Activityは一つだけ
ActivityはMainActivity1つしか作らない。
Activityは画面というよりアプリケーションに近く以下のような性質を持つ。
- データの受け渡し方法が特殊(Intent)
- 起動モードを持つ(launchMode)
- Stateの復元を考慮する必要がある(savedInstanceState)
これらの性質はUIViewControllerで作られたiOSの画面とマッチしない部分が多いので、Activityは画面ごとに作らずアプリケーションで一つだけにする。
大丈夫なの?
React NativeやAdobe AIRといった、そこそこメジャーなフレームワークが1Activity構成になっているのでたぶん大丈夫。
後で知ったが、以下のような記事もみつけた。
「アプリにActivityはひとつでいい」という神のお告げ
→ 神は言っているらしい。
モダンなAndroid開発: アクティビティとフラグメントなんて捨ててしまおう
→ モダンらしい。
Activityとアプリケーションのライフサイクル
ここで注意するのは、アプリケーションとActivityがライフサイクルをともにすること。
具体的にはActivityの終了時にメモリに保持した値を破棄する。
AndroidはメインActivityとJavaプロセスのライフサイクルが同期しないという厄介な仕様がある。
Activityが死んだがJavaのstaticフィールドが残ったり、逆にActivityが生きているのにJavaのstaticフィールドが消えたりする。
そのため、Activity終了時にメモリを破棄しないと、Activityの再起動時に前回起動時の情報が残った状態になってしまう。
メインActivityのonCreateで起動処理をし、onDestroyで全ての値をクリアすることで、アプリケーションとActivityがライフサイクルをともにするようにする。
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のレイアウトファイルで画面を作れるようにする。
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() {
view.viewTreeObserver?.removeOnGlobalLayoutListener(this) // viewDidAppearより先にやる
viewDidAppear(false)
}
}
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 でセットする。
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。クラス名からレイアウトファイル名に変換するのに使った。
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のプロパティを追加している。
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コンポーネントを継承して作る。
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)
}
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
は以下のような形になる。
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などのインターフェースを定義する。
interface StringEnum {
val rawValue: String
}
上記のインターフェースを使うと、Swift、Kotlinそれぞれでのenumの書き方は以下のようになる。
enum Status: String {
case success = "0"
case warning = "1"
case error = "2"
}
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はコンストラクターでインスタンス化できないので、クラスオブジェクトから取得するようにした。
inline fun <reified T> KClass<T>.value(rawValue: String?): T? where T : Enum<T>, T : StringEnum {
return enumValues<T>().firstOrNull { it.rawValue == rawValue }
}
使い方を比較すると以下のようになる。
var enum = Status(rawValue: "0")
var enum = Status::class.value(rawValue: "0")
::class
が冗長なのでもっと良い書き方があれば教えて欲しい。
rawValueからenumの値を取得2 (2019-05-21追記)
iOSと同じような書き方でrawValueからenumを取得する方法を知ったので追記する。
enum class Foo (val rawValue: String) {
success("success"),
error("error");
companion object {
operator fun invoke(rawValue: String) = values().firstOrNull { it.rawValue == rawValue }
}
}
// iOSとほぼ同じ書き方でenumを取得できる
Foo(rawValue = "success")
enum class の companion objectにoperator fun invoke
を定義してやるとiOSと同じような書き方でenumを取得することができる。
operator fun invoke
は括弧()
で呼び出せる関数で、これを定義すると以下のように変なことができる。
operator fun Int.invoke() {
println(this)
}
1() // 1がprintlnされる
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それぞれでの使い方は以下のようになる。
enum Status: String {
case success
case warning
case error
}
print(Status.success.rawValue) // > "success"
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と似せることができる。
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)
}
var i: Int = 0 {
didSet {
print(i)
}
}
var i: Int by didSet(0) {
print(i)
}
上記の形だと、oldValueを参照できないが、少し改造すればoldValueも使える。
アプリケーションサンプル
今まで説明したUIViewControllerなどを使い、実際に動く簡単なアプリケーションの例がこちら。
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)
}
}
<?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>
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"
}
}
<?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)にも反応する。
var _list = [String]()
var list: [String] {
get {return _list}
set(value) {
_list = value
print("セッター呼ばれた")
}
}
func append(item: String) {
list.append(item) // これでセッターが呼ばれる!!!!
}
これはちょっとだけ便利な仕様だと思うが、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 // 代入するとセッターが呼ばれる
}