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エンジニア、データ分析屋さんも歓迎しています。
BDD(振る舞い駆動開発)に則った自動テストでiOSアプリの開発速度を高める
この記事はiOS2 Advent Calendar 2017の8日目の記事です。
私事で恐縮ですが数ヶ月前に株式会社Globeeという会社のCTOに就任しまして、今はabceed analyticsという教育系アプリを開発しています。前職ではHadoop系を活用したログ収集基盤やログ解析基盤を担当していたので分野的には割と大きく変わりました。
さて、弊社のような小規模なスタートアップでは開発速度が重視されるため、自動テストがどうしても疎かになりがちです。
しかし個人的には小規模なスタートアップであっても、いけると思ったプロダクトならテストコードは書くべきだと考えています。理由はシンプルで、テストコードを書いた方が長期的に見て開発速度が上がるからです。
というわけで今回は弊社開発のアプリに自動テストを導入した時の考え方について話します。「うちはこうしている」などのアドバイス・ツッコミがありましたら是非コメントで教えてください!
テストコードを書く事で開発速度が上がる理由
私はテストコードを書く事で開発速度が上がる理由は大きく分けて以下の3つだと思っています。
コードを改修した時にバグが起きにくくなるので、バグの原因を調査する時間が少なくなり開発速度が上がる
テストコードがあることで新規メンバーが既存コードの仕様を理解しやすくなるので、チーム開発の速度が上がる
テスト可能なコードを意識する事でプロジェクト全体のコード品質が上がるので、長期的に見た時に開発速度が上がる
この辺りについては先日以下のような記事も上がっていましたので、既に認識済みの方も多いかと思います。
フロント開発における自動テスト
フロント開発はUI部分に頻繁に改修が入るため、バックエンド開発に比べてテストコードが形骸化しやすいです。ですので、個人的な意見ですがフロント開発ではテストカバレッジをそこまで追い求める必要は無いと思っています。
その辺を踏まえて、弊社では現在BDD(振る舞い駆動開発)の思想を採用しています。
BDD(振る舞い駆動開発)とは
BDDはTDDの亜種のようなもので、概要は以下です。
テストを「振る舞い」(機能的な外部仕様)の記述に特化させる、つまりユーザー目線でのテストとなる。
テストを実行可能なドキュメントとして扱い、テストの可読性を重視する。また、テストはユースケースの粒度で書かれる。つまり、テストコード=詳細設計書のような扱いとなる。
振る舞いをテストするのであってコードをテストするのではないので、カバレッジはそこまで重要視しない。
詳しく知りたい人は以下のサイトも参考にしてください。
特に重要だと思っているのが、テストを実行可能なドキュメントとして扱うという部分です。新規メンバーでもテストコードを読めば大体何をしているアプリなのか分かる、というのが理想かと思います。
SwiftではQuickがBDDテストフレームワークの代表格なので今回はそちらを採用しました。では実際にQuickを使った自動テストがどのようになるのか見てみましょう。
テストの前に
最初にも言いましたが、自動テストを導入するならコードがテスト可能な設計になっている必要があります。 超ざっくり言ってしまうと、
- ネストが深すぎるコード
- 長すぎるメソッド
- でかすぎるViewController
- 状態を持つシングルトン
があると自動テストが書きづらいです。
この辺はプロジェクトで使う設計手法を固めてしまえばある程度大丈夫かなと思います。 私はClean Architectureが一番しっくりきたのでそれを使っています。
Clean Architectureについて詳細は
などをご参照下さい。
Quickを使ったシンプルな自動テストの例
前置きが長くなってしまいましたが、Quickを使って自動テストを書いてみます。
例えば特定の問題集がお気に入り登録されているかどうかを判定する以下のようなユースケースクラスがあったとします。(簡易化のためRepository層を切らずにRealmにアクセスしています)
class IsBookFavoritedUseCase { func execute(_ id:String) -> Bool { let realm = try! Realm() let predicate = NSPredicate(format: "id_book == %@",id) if let _ = realm.objects(MyBook.self).filter(predicate).first { return true } else { return false } } }
このクラスに対してテストを書くと、以下のようになります。
class IsBookFavoriteUseCaseSpec : QuickSpec { override func spec() { let isBookFavoritedUseCase = IsBookFavoritedUseCase() describe("特定の問題集がお気に入り登録されているかどうかを判定できる") { let favoritedId = "book_favorited" let unfavoritedId = "book_unfavorited" beforeEach { //テストデータの準備 } it("指定されたIDの問題集がお気に入り登録されていた場合はtrueを返す") { let isFavorited = isBookFavoritedUseCase.execute(favoritedId) expect(isFavorited).to(equal(true)) } it("指定されたIDの問題集がお気に入り登録されていない場合はfalseを返す") { let isFavorited = isBookFavoritedUseCase.execute(unfavoritedId) expect(isFavorited).to(equal(false)) } } } }
中々可読性が高く、テストコードを読むだけでクラスの仕様が分かると思うのですがどうでしょうか?(テスト名に日本語を使うのが嫌いな人もいるかと思いますが)
ポイントはテスト名にメソッド名ではなく要求される振る舞いを記述しているという事です。これはBDDではコードをテストしているのではなく振る舞いをテストしているからです。
依存性を持つクラスの自動テスト
さて、次は現在地の緯度経度を元にユーザーが日本にいるかどうかを判定する以下のようなクラスを考えてみます。
class IsUserInJapanUseCase { func execute() -> Bool { let location = LocationGetter.shared.getUserLocation() return isInJapan(location) } private fun isInJapan(location:CLLocation?) -> Bool { //do some check process } }
このクラスは位置情報取得処理がLocationGetter
(架空のクラスです)に依存しているため、このままでは単体テストを行うことができません。
このような状態を実装に依存していると呼びます。実装への依存を避けるためのデザインパターンがDI(依存性注入)です。
DIで実装依存を避ける
サービスクラスをインタフェース経由で使うようにし、実体を外部から渡せるようにするのがDIです。 詳しく知りたい方は以下の記事が良いかもしれません。
今回の例だと、LocationGetterは現在地を取得し返すという振る舞いを持つクラスです。なので、まずはprotocolとしてその振る舞いを定義します。
protocol LocationAccessable { func getUserLocation() -> CLLocation? }
次にLocationGetter
はLocationAccessable
を継承するようにし、IsUserInJapanUseCase
もLocationAccessable
経由で位置情報の取得を行うようにします。
class LocationGetter: LocationAccessable { func getUserLocation() -> CLLocation? { //CLLocationManagerを用いた実際の位置情報取得処理を実装 } }
class IsUserInJapanUseCase { var locationAccessor:LocationAccessable func execute() -> Bool { let location = locationAccessor.getUserLocation() return isInJapan(location) } private fun isInJapan(location:CLLocation?) -> Bool { //do some check process } }
以上のようにする事でIsUserInJapanUseCase
は位置情報取得の実装クラスを外部から渡せる実装に依存しないクラスとなり、単体でテスト可能となりました。では実際にテストを行ってみましょう。
テストのためにLocationAccessable
プロトコルを継承したモッククラスを作成します。
class FakeLocationGetter: LocationAccessable { var fakeLocation = CLLocation(latitude: 35.666401, longitude: 139.754207) func getUserLocation() -> CLLocation? { return fakeLocation } }
このモッククラスは、fakeLocation
の値を変える事で簡単に返却値を切り替える事ができます。このクラスをテスト実行時にIsUserInJapanUseCase
に以下のように注入します。
class IsUserInJapanUseCaseSpec : QuickSpec { override func spec() { let isUserInJapanUseCase = IsUserInJapanUseCase() let fakeAccessor = FakeLocationGetter() describe("現在地の緯度経度を元にユーザーが日本にいるかどうかを判定できる") { beforeEach { isUserInJapanUseCase.locationAccessor = fakeAccessor } it("日本の緯度経度にはtrueを返す") { fakeAccessor.fakeLocation = CLLocation(latitude: 35.666401, longitude: 139.754207) let isInJapan = isUserInJapanUseCase.execute() expect(isInJapan).to(equal(true)) } it("日本以外の緯度経度にはfalseを返す") { fakeAccessor.fakeLocation = CLLocation(latitude: 46.532219, longitude: 116.460937) let isInJapan = isUserInJapanUseCase.execute() expect(isInJapan).to(equal(false)) } } } }
このようにDIとモッククラスを活用することで、依存性を持つクラスでもテストを行うことができます。
今回は位置情報取得部分を例としましたが、API通信・ポップアップ表示・ローカル通知などもこの方法でテストを行うことができます。
詳しくは以下が参考になるかと思います。
まとめ
自動テストの導入には若干のコストがかかりますが、長期的に見れば絶対にペイすると思います。小規模なスタートアップであっても、このプロダクトはいけると思ったら積極的に自動テストを導入しましょう!
なお、弊社では現在エンジニアを募集中です。こんな長い記事を最後まで読んでくださった方は多分技術が好きな方だと思います。もし弊社に興味ありましたら是非一度お話しさせてください!
UdacityのDeep Learning Nanodegree Foundationを修了しました
UdacityのDeep Learning Nanodegree Foundationを修了しました。受講期間は約4ヶ月でした。
Deep Learning Nanodegree Foundation | Udacity
主な学習内容
- numpyでの多層パーセプロトン実装(これ以降は全てTensorFlow)
- 畳み込みニューラルネットによる画像分類
- RNNによるTV番組の会話文字列生成
- LSTMによる英語-フランス語翻訳
- DCGANによるMNIST, CelebAの画像生成
課題の内容は GitHub - udacity/deep-learningで確認できます。
受講した感想
4ヶ月という短期間でDeep Learningの分野を幅広く学べたので、初学者の入りとしては中々良かったかなと思います。
レビュアーがついてくれるので、行き詰まるということがあまり無かったのが好印象です。例えば「この場合weightの初期化を一様分布では無く正規分布で取った方が良い」とか、「Labelをスムージングした方が良い」とか初学者が気づきにくいようなところを指摘してくれるのは有り難かったです。学習の助けになりそうな記事もたくさん紹介してくれました。(全て英語記事ですが)
紹介してくれた英語記事などは個人的な備忘録としてこちらにまとめていますので、もし興味あれば。
ただ4ヶ月で幅広くやるという都合上、1つの分野の掘り下げが浅くなりがちだったので、興味を持った分野に関してはもう少し自分で学習しようと思います。
注意点
僕が登録した時は受講費用は$499でしたが、まあ安くは無い金額だと思います。なので一応注意点を書いておきます。
あくまで初学者向け
ターゲットは初学者向けなので、既にディープラーニングをある程度知っている方にはやや物足りない内容かもしれません。 GitHub - udacity/deep-learningを見て自分のレベルに見合うものかどうかを確認すると良いかと思います。
そこそこの学習コストがかかる
大体週8時間ぐらいは学習する必要があるかと思います。英語が苦手だともう少しかかるかもしれません。 また、課題ごとにタイムリミットがありますので注意です。
多少のプログラミング知識が必要
講義は全てPythonで行われますので、プログラミング自体が初めてだと厳しいかと思います。また、数式はあまり出てきませんが行列計算と微分積分の基礎ぐらいは必要な気がします。
Programming knowledge needed: Basic to intermediate Python, experience with Numpy. Anaconda and Jupyter Notebooks.
まとめ
少し高いですが、Deep Learningを初めて学習するなら悪くない選択肢だと思います!