iOS/Androidの同時開発を高速化する設計手法について
明けましておめでとうございます。今年もよろしくお願い致します。
弊社では現在abceed analyticsというアプリを開発していますが、iOS/Androidのアプリを両方作る際の開発工数を削減したいというのは人類共通の課題かと思います。
そこで今回は、弊社がアプリ開発を高速化するために採用している手法・技術スタックについて簡単に書きます。 なお、React Native / Xamarin / Cordova などのクロスプラットフォーム系フレームワークについては触れません。あくまでネイティブで開発する際の手法ですのでご了承ください。
前提となる考え方
弊社では、iOS/Androidで新機能を同時にリリースすることは行なっておりません。多くの場合先行してiOS版で新機能をリリースし、遅れてAndroid版をリリースしています。 同時リリースを行なっていない理由は主に2つあります。
1つ目の理由は、新機能はリリース後にユーザーの反応を分析して変更が加えられる可能性が高いからです。 ソフトウェア企業が陥りがちな問題として「5%問題」というものがあります。これは、サービスを多機能化しすぎて何がサービスの核なのかを見失ってしまう、というものです。
アプリに新機能を追加する際は、実装する前にその機能がサービスの核を損なわないかを十分考えるようにしていますが、実際にリリースしてみないと分からない事もあります。時にはリリース後に新機能を撤回することもありますので、まず片方のOSでリリースし、価値が高いことが分かったらもう片方のOSでもリリースするという方法が良いと感じています。※絶対に必要な機能である事が明確な場合や、開発リソースが潤沢な場合は別です
2つ目の理由は、UseCase層から先のコードはかなりの部分をiOS/Android間で使いまわせるからです。
後述しますが、弊社ではiOS/Android共にClean Architecture
を使った設計を行っています。この場合、ビジネスロジックがViewや外部インフラに依存しなくなるので、コピペ + 一手間ぐらいの感覚で使いまわせるようになります。
ほぼ同じ内容のコードを2人の人間が別々に作るというのは効率が悪く、1人が作った後に使い回す方が開発工数・バグの出にくさの両面で優れていると感じています。ここに関しては考え方が分かれるかと思いますので、是非ご意見ください。
言語
iOSではSwift
を、AndroidではKotlin
を使用しています。
Kotlinを使うべきかどうかに関しては諸説あるかとは思いますが、コードをiOS/Android間で使い回す際のやりやすさを重視しました。特にOptionalが言語レベルでサポートされている事が非常に大きいです。
Swiftでよく使うif let
やNil Coalescing Operator
もKotlinなら簡単に移植する事ができます。optional chaining
に至っては全く一緒の構文です。
//Swift //if let if let book = bookOptional { print(book.name) } //Nil Coalescing Operator and optional chaining print(bookOptional?.name ?? "book is null")
//Kotlin //if let bookOptional?.let { print(it.name) } //Nil Coalescing Operator and optional chaining print(bookOptional?.name ?: "book is null")
あとはimmutable
の宣言をval
とlet
のどちらかに揃えて欲しいと切に願っています。
設計手法
個人のブログでも何回か触れていますが、設計手法はiOS/Android共にClean Architecture
を使っています。
詳しい内容についてはここでは触れませんが、ロジックを層ごとに分け、層と層の間の依存関係を無くす事でコード全体の見通しが良くなります。
パッケージ構成は超ざっくりだとこんな感じです。domainパッケージに関してはiOS/Android間でほぼほぼ使いまわせます。通常のClean Architecture
を少し簡略化しているのでご注意ください。
├ data │ ├ network │ └ repository ├ di ├ domain │ ├ value │ ├ entity │ ├ model │ └ usecase ├ presentation ├ service ├ utils
UseCase層クラスの切り方は、エンティティでざっくり切るのとユースケース別に細かく切るのと2つあると思いますが、後者の方が見通しが良くなって移植はしやすい気がしています。ただクラス数が増えすぎるのでここは好みかもしれません。
class UserCRUDUseCase @Inject constructor(val rep: UserRepository) { fun register() { //some process } fun update() { //some process } fun delete() { //some process } }
class RegisterUserUseCase @Inject constructor(val rep: UserRepository) { fun execute() { //some process } }
設計手法は色々あるので好きなものを使えば良いと思いますが、迷っているならGoogleが公開しているAndroid Architecture Blueprints
を参考にするのがオススメです。
ライブラリ
iOS/Androidで使っているライブラリについてです。できるだけOS間で使用感が変わらないようなものを選んでいます。
API
iOSではAPIKit + ObjectMapperを、Androidではretrofit2 + Moshiを使っています。
APIのアクセス情報はdata/network
に格納します。APIのレスポンスに関してはdomain/entity
に格納していますが、data
層に格納して変換をdomain
層で噛ませた方が良いと思う時もあります。
iOS
class UserInfoRequest: BaseAPIRequestType { var path: String { return "user/info" } var method: HTTPMethod { return HTTPMethod.get } //Response typealias Response = UserInfoResponse var id_user:String init(id_user:String) { self.id_user = id_user } //Request Parameters func toDict() -> Dictionary<String, Any> { var dict = Dictionary<String, Any>() dict["id_user"] = id_user return dict } } class UserInfoResponse: Mappable { var id_user:String? var name_user:String? required init?(map: Map) { } func mapping(map: Map) { id_user <- map["id_user"] name_user <- map["name_user"] } }
Android
interface UserApi { @GET("user/info") fun info(@Query("id_user") id_user:String): Call<UserInfoResponse> } data class UserInfoResponse(val id_user:String, val name_user:String)
内部DB
内部DBはiOS/Android共にRealmを使っています。シンプルで使いやすいですが、スレッドをまたぐ時に注意が必要です。 AndroidではUseCase層を別スレッドで実行する事が多いので、都度View用のモデルに変換しています。ここに関して良い方法あれば是非教えてください。
DI
AndroidではDagger2を導入しています。AndroidはContextが必要な箇所が多いのですが、Contextを引き回しているとコードの見通しが悪くなるのでDIを導入する利点が大きい気がしています。
DIを使うと、例えばSharedPreferenceが以下のようにRepository層に渡せるので、個人的に書きやすいです。
@Module class AppModule { @Provides @Singleton fun provideContext(application: Application): Context { return application } @Provides @Singleton @Named("default") fun provideDefaultSharedPreference(context: Context): SharedPreferences { return PreferenceManager.getDefaultSharedPreferences(context) } } @Module internal class RepositoryModule { @Provides @Singleton fun provideUserRepository(@Named("default") ref: SharedPreferences): UserRepository { return UserRepository(ref) } } class UserRepository @Inject constructor(val ref: SharedPreferences) : UserDataSource { }
iOSに関しては、良いライブラリを見つけられていないこともあり手動でのDIとなってしまっています。良い方法ありましたら誰か教えてください。
まとめ
弊社では、
まず片方のOSでリリースし、価値が高いことが分かったらもう片方のOSでもリリースする
Kotlinを採用し、Swiftからの移植コストを下げる
といった方法で開発の高速化を図っています。 まだまだ拙いところも多くありますので、もし本記事にご意見などありましたら教えていただければ幸いです!
なお、弊社では現在エンジニアを募集中です。この記事を読んでもし興味がおありでしたら是非オフィスに遊びに来て下さい! アプリエンジニア以外にもWebエンジニア、データ分析屋さんも歓迎しています。