はじめに
「J2ObjCでJavaとSwiftを連携させる」で紹介したようにJ2ObjCを利用することで、iOS/Androidでモデル部分をJavaで共通化することができます。
最近はスマホアプリの開発でC#発祥のMVVM的なView-ViewModel-Modelに分割するパターンが有用と認識されてきています。MVVMでは各層のやりとりはコマンドとイベントで行うため、イベント監視1にObserverパターンを用いることになるかと思います。
Observerパターンをさらに拡張したやはりC#発祥のReactive Extension(略してRx)はMVVMと相性が良く、最近関連記事を多く見かけます。
というわけでRxのJava版、RxJavaをJ2ObjCで利用できるか試してみました。何度もJava -> Objective-C変換やコンパイルするのは時間の無駄なので、スタティックライブラリ化します。利用したのはRxJava 1.1.0です。
RxJavaのコード修正
J2ObjCは1ファイル複数クラスをうまく扱えないようです。RxJavaには rx/internal/util/unsafe/BaseLinkedQueue.java の中に複数クラスが含まれています。これをまず1ファイル1クラスになるように複数ファイルに分割しておきます。
Xcodeプロジェクト作成
XcodeからiOSのCocoa Touch Static Libraryプロジェクトを作成してください。名前はRxJavaとしておきましょう。
RxJavaフォルダの中身は実ファイルごと削除しておきます。
JavaからObjective-Cへの変換
Javaソースはもういじらないので、Objective-Cへの変換はあらかじめやっておきます。シェルスクリプトにしました。スクリプトにしておけばJ2ObjCがバージョンアップした時に楽に再変換かけられます。
RxJavaのソースはsrcに格納しています。
#!/bin/sh
DERIVED_FILES_DIR=RxJava
SOURCE_PATH=src
J2OBJC_DIST=~/Library/j2objc
JAVA_SOURCES=`find ${SOURCE_PATH} -type f -name *.java`
rm ${DERIVED_FILES_DIR}/*
for INPUT_FILE_PATH in ${JAVA_SOURCES};
do
${J2OBJC_DIST}/j2objc -d ${DERIVED_FILES_DIR} \
-sourcepath ${SOURCE_PATH} \
--no-package-directories ${INPUT_FILE_PATH};
done
実行すると、変換されたファイルがRxJavaフォルダに出力されます。
$ ./translate.sh
Xcodeプロジェクトの設定
Xcode上のRxJavaグループに、生成されたファイルを全て追加します。
Build Settingsで
- USER_HEADER_SEARCH_PATHSに"${HOME}/Library/j2objc/include"を追加
- CLANG_ENABLE_OBJC_ARCをNOにしてARCを無効に
universalライブラリのビルド
実機とシミュレータの両方のバイナリを含んだuniversalバイナリを作成するシェルスクリプトを作成しました。
#!/bin/sh
#-----------------
# Settings
#-----------------
TARGET=RxJava
LIB_FILE_NAME=lib${TARGET}.a
OUTPUT_DIR=Release/${TARGET}
CONFIGURATION=Release
#CONFIGURATION=Debug
#-----------------
# Clean up
#-----------------
xcodebuild -configuration ${CONFIGURATION} -target ${TARGET} clean
rm -rf build
rm -rf ${OUTPUT_DIR}
#-----------------
# Build
#-----------------
xcodebuild -configuration ${CONFIGURATION} -target ${TARGET} -sdk iphonesimulator
[ $? -ne 0 ] && exit 1
xcodebuild -configuration ${CONFIGURATION} -target ${TARGET} -sdk iphoneos
[ $? -ne 0 ] && exit 1
#-----------------
# Copy library
#-----------------
mkdir -p ${OUTPUT_DIR}/include
lipo -create \
build/${CONFIGURATION}-iphoneos/${LIB_FILE_NAME} \
build/${CONFIGURATION}-iphonesimulator/${LIB_FILE_NAME} \
-o ${OUTPUT_DIR}/${LIB_FILE_NAME}
#-----------------
# Copy headers
#-----------------
cp ${TARGET}/*.h ${OUTPUT_DIR}/include
for f in `ls ${TARGET}/*.h`; do
echo '#import "'${f##*/}'"';
done > ${OUTPUT_DIR}/include/RxJava.h
実行するとReleaseの中に結果が格納されます。
$ ./build.sh
利用設定
まずは以前の記事の通り、Javaで記述するModel部分(MVVMならViewModeとModel)をstatic libraryターゲット化してください。
先ほどReleaseの中にできたRxJavaフォルダをまるごとプロジェクトのルートにコピーします。プロジェクトにRxJavaフォルダごと追加します。
アプリターゲットにリンクの設定がされているか確認してください。されていなければ追加してください。Model側は逆にリンクしていないことを確認してください。
LIBRARY_SEARCH_PATHSに"$(PROJECT_DIR)/RxJava"を追加されているか確認してください。されていなければ追加してください。
Modelターゲットとアプリターゲットの両方のUSER_HEADER_SEARCH_PATHSに"$(PROJECT_DIR)/RxJava/include"を追加します。
RxJavaのjarファイルがないとJavaファイルの時点でRxJavaが見えません。RxJavaフォルダのなかにrxjava-1.1.0.jarファイルを入れておきます。
ModelターゲットのBuild Rules - Java source files using Scriptのスクリプト部分に"-classpath ${PROJECT_DIR}/RxJava/*.jar"引数を追加します。
変更後のスクリプトは以下のようになります。
${HOME}/Library/j2objc/j2objc -d ${DERIVED_FILES_DIR} \
-classpath ${PROJECT_DIR}/RxJava/*.jar \
--prefixes ${PROJECT_DIR}/Model/prefixes.properties \
--no-package-directories ${INPUT_FILE_PATH};
アプリ側でRxJavaのクラスが見えるように、RxJava.hをModel.hかSampleJ2ObjC-Bridging-Header.hで#importしておきます。
#import "Config.h"
#import "RxJava.h"
利用してみる
Model側
package app.model;
import rx.Observable;
import rx.subjects.BehaviorSubject;
class Config {
private static final int DEFAULT_SPEED = 20;
private BehaviorSubject<Integer> _speed;
Config() {
reset();
}
public void reset() {
_speed.onNext(DEFAULT_SPEED);
}
public Observable<Integer> getSpeed() {
return _speed;
}
public void setSpeed(int speed) {
_speed.onNext(speed);
}
}
アプリ側
Model側の中でだけ使うなら普通にJavaで使うのと同じです。問題はこれをアプリ側のSwiftから使う場合です。subscribeWithRxFunctionsAction1()メソッドを呼び出すのですが、これがRxFunctionsAction1プロトコルを実装したオブジェクトを要求します。
import UIKit
class ViewController: UIViewController {
private let config = Config()
private var speedSubscription: RxSubscription?
override func viewWillAppear(animated: Bool) {
// Swiftに無名クラスってないの(ToT)
// しょうがないからRxFunctionAction1プロトコルを満たすクラスを作ってオーバーライド
class SpeedAction : NSObject, RxFunctionsAction1 {
@objc func callWithId(t: AnyObject!) {
if let value = t as? Int {
print("speed changed: " + String(value))
}
}
}
let speed = config.getSpeed()
speedSubscription = speed.subscribeWithRxFunctionsAction1(SpeedAction())
}
override func viewWillDisappear(animated: Bool) {
speedSubscription?.unsubscribe()
}
}
Swiftではラムダが使えるから無名クラスって仕組みはないっぽい。Javaの無名クラス作って渡す仕組みは使いづらいですね。
ラッパー
SwiftからRxJavaを使うならラッパーを作った方が良さそうです。こんな感じ?
import Foundation
/// RxSubscriptionを格納しておき、消滅時にまとめてunsubscribeする
class SubscriptionBag {
private var _subscriptions = [RxSubscription]()
func add(subscription: RxSubscription) {
_subscriptions.append(subscription)
}
deinit {
_subscriptions.forEach { $0.unsubscribe() }
}
}
/// SubscriptionBagに格納する拡張メソッドを提供
extension RxSubscription {
func addToBag(subscriptionBag: SubscriptionBag) {
subscriptionBag.add(self)
}
}
/// 関数をRxFunctionAction1でラップするクラス
class Action1Binder<T>: NSObject, RxFunctionsAction1 {
private let _action: T -> Void
init(action: T -> Void) {
_action = action
}
func callWithId(t: AnyObject!) {
if let value = t as? T {
_action(value)
}
}
}
/// RxObserbableへの購読で引数に関数を使えるようにする拡張
extension RxObservable {
func subscribeOnNext<T>(onNext: T -> Void) -> RxSubscription {
return subscribeWithRxFunctionsAction1(Action1Binder(action: onNext))
}
}
利用する側は
import UIKit
class ViewController: UIViewController {
private let config = Config()
private var subscriptionBag = SubscriptionBag()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
let action: Int -> Void = { print("speed changed: " + String($0)) }
config.getSpeed().subscribeOnNext(action).addToBag(subscriptionBag)
}
}
subscribeOnNext<Int> { value in /* ... */ } って書くとエラーになります。少なくともSwift 2.1ではジェネリクス関数の型を明示するのはダメみたいです。この辺りはもうちょっと書きやすいように工夫したいですね。
変換後のクラス名やメソッド名
変換後のクラス名はrx.functions.Action1はRxFunctionsAction1になって長ったらしいです。名前が衝突しないなら全てRx2になるようにした方が扱いやすそうです。方法については以前の記事に書いたのでそちらを参照してください。
メソッド名の変換もカスタマイズできるみたいなので、気にいらなければ改造できます。J2ObjCのChanging Method Namesのページを参照してください。