明けましておめでとうございます。Globee CTOの上赤です。
今年もよろしくお願い致します。
弊社では現在abceed analyticsというアプリを開発していますが、iOS/Androidのアプリを両方作る際の開発工数を削減したいというのは人類共通の課題かと思います。
そこで今回は、弊社がアプリ開発を高速化するために採用している手法・技術スタックについて簡単に書きます。
なお、React Native / Xamarin / Cordova などのクロスプラットフォーム系フレームワークについては触れません。あくまでネイティブで開発する際の手法ですのでご了承ください。
前提となる考え方
弊社では、iOS/Androidで新機能を同時にリリースすることは行なっておりません。多くの場合先行してiOS版で新機能をリリースし、遅れてAndroid版をリリースしています。
同時リリースを行なっていない理由は主に2つあります。
1つ目の理由は、新機能はリリース後にユーザーの反応を分析して変更が加えられる可能性が高いからです。
ソフトウェア企業が陥りがちな問題として「5%問題」というものがあります。これは、サービスを多機能化しすぎて何がサービスの核なのかを見失ってしまう、というものです。
参考リンク - Evernoteを苦しめる「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
を使っています。
参考リンク - まだMVC,MVP,MVVMで消耗してるの? iOS Clean Architectureについて - Qiita
詳しい内容についてはここでは触れませんが、ロジックを層ごとに分け、層と層の間の依存関係を無くす事でコード全体の見通しが良くなります。
パッケージ構成は超ざっくりだとこんな感じです。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
を参考にするのがオススメです。
参考リンク - GitHub - googlesamples/android-architecture
ライブラリ
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からの移植コストを下げる
- Clean Architectureを採用してビジネスロジックを簡単にiOS/Android間で使い回せるようにする
- AndroidでDagger2を採用し、Repository層の設計がiOS/Androidで同じになるようにする
といった方法で開発の高速化を図っています。まだまだ拙いところも多くありますので、もし本記事にご意見などありましたら教えていただければ幸いです!
なお、弊社では現在エンジニアを募集中です。この記事を読んでもし興味がおありでしたら是非オフィスに遊びに来て下さい!アプリエンジニア以外にもWebエンジニア、データ分析屋さんも歓迎しています。