CLINICS アプリのリニューアルの裏側 (iOS 編)
こんにちは、医療プラットフォーム本部/プロダクト開発室/第一開発グループ所属の世嘉良です。 メドレーには 2018 年の頭に入社しており、今年で 4 年目になります。 当初はサーバーサイドを中心に開発を担当していたのですが、最近は患者エンゲージメントチームという患者様に提供するサービスを開発するチームで主に iOS の仕事を担当することが多いです。
さて去年の 12 月になりますが、 CLINICS アプリ は UI のフルリニューアルを行いました。 今回はリニューアルの裏話 (iOS について) をしていきたいと思います。
これまでの CLINICS アプリについて
本題を書く前に、CLINICS アプリの歴史を紹介します。 ファーストコミットを見てみると、アプリの開発は 2016 年 2 月ごろからスタートし、ファーストリリースが行われたのが 2016 年 5 月でした。
当初、担当していたエンジニアは iOS の経験が豊富な方はおらず、全員で試行錯誤しながら開発を進めていたようです。 しばらくの間は機能の追加などが行われていましたが、CLINICS カルテ の開発に注力するために大きな開発はストップし、Pharms との連携が開始される 2020 年 5 月頃まで機能や設計に関する見直しがほとんど行われてきませんでした。
iOS に詳しいエンジニアがいなかったにも関わらず、かなりのスピード感でリリースしていることはさすがの開発力という一言に尽きるのですが、Pharms との連携やお薬手帳といった機能が追加されたり、今後の開発を見据えた際に既存機能や設計の見直しを行いたいと思うようになってきました。
改善したい部分はたくさんあったのですが、対応工数を踏まえ今回のリニューアルでは以下の 2 点の改善に注力することにしました。
- Storyboard による View の管理
- View とロジックの分離
この 2 点についてそれぞれ詳しく説明します。
1. Storyboard による View の管理
従来の開発では Storyboard を利用して View を作成していました。 Storyboard を使うとレイアウトや画面遷移を簡単に実装することができますが、開発を続けているうちに以下のような問題が目立つようになってきました。
- Storyboard が巨大化していき、複数人開発を行う際に支障がでる
- レイアウトに追加・変更が行われた場合に AutoLayout の再設定に時間がかかる
- コンポーネント自体にサイズが設定されていることがあり、コンポーネントを再利用できないことがある
課題
こちらは実際にあった Storyboard のキャプチャですが、複数の画面が 1 つの Storyboard 内に詰め込まれた状態となっていました。 Storyboard は Xcode のバージョンにより微妙な差分が発生してしまったり、AutoLayout の調整が必要になる場合があります。
こちらは古い Storyboard の画面を開いた後の差分なのですが、この変更がシステムによって加えられた変更なのか、他人の改修による変更なのかを後から見た時にわかりづらいという問題がありました。
仮に問題が発生した場合は修正を試みるのですが、独自の XML によって表現されているため、iOS の経験が浅い僕にとっては修正が非常に難しかったです。
また、複数人で並行して開発する際にもコンフリクトが起きやすくなってしまい解消に時間がかかるという問題となっていました。
さらに従来の CLINICS アプリは DLS を利用してレイアウトを作成していたのですが、コンポーネントに直接サイズが設定されているものがあり、ある画面では微妙にサイズを調整したい…といった要件に対応できず、せっかくのコンポーネントを再利用できないケースがありました。
解決案
課題に対する解決案は色々と考えられますが、弊社の開発スタイルやエンジニアのスキルを考慮し以下のような方針を立てました。
- Storyboard と各画面の実装は 1 : 1 の関係とする
- 画面遷移の責務も Storyboard から切り離す
- コンポーネントにアトミックデザインを適用する
Storyboard の分割は以下のようなステップで行っていました。
- Refactor to Storyboard を使って巨大な Storyboard を分割する
- SwiftGen を利用し画面生成のコードを自動作成する
- 2 で生成したものを利用して画面遷移を行う
- 既存の Segue を削除する
まず最初に Xcode 7 から利用可能となった「Refactor to Storyboard」を使って画面を分割するところから始めます。
この機能を利用すると選択した View が新しい Storyboard に切り出され、元の Storyboard には切り出した Storyboard へのリファレンスや Segue 等の接続が保持された状態になります。
次に各画面間の遷移を Segue を使わずに行うようにします。 Segue は便利なのですが、Identifier が単なる文字列であったり、Storyboard を分割してもリファレンスは保持しておく必要がある点が微妙に感じてしまい利用しないことにしました。
Segue を使わずに画面遷移を行う必要があるため、画面生成と画面遷移の方法を自前で実装する必要があります。 今回は画面生成の処理を SwiftGen を利用して自動生成可能にし、VIPER というアーキテクチャの Router を参考にして画面遷移を Storyboard から切り離すことにしました。
※ SwiftGen の利用方法は SwiftGen#interface-builder を参照ください。
Router の実装は以下の通りです。 実装されたプロトコルを遷移元の VC に継承し、画面遷移のコードを呼び出すだけで画面遷移を行うことができます。
protocol ClinicWireframe: AnyObject {
var viewController: UIViewController { get }
func presentClinic(clinicId: String)
func pushClinic(clinicId: String)
}
extension ClinicWireframe {
func presentClinic(clinicId: String) {
let vc = StoryboardScene.Clinic.initialScene.instantiate {
ClinicViewController(coder: $0, isHeaderEnabled: true)
}
let presenter = ClinicPresenter(view: vc)
presenter.setClinicId(clinicId: clinicId)
vc.inject(presenter: presenter)
vc.modalPresentationStyle = .pageSheet
let nc = UINavigationController(rootViewController: vc)
viewController.present(nc, animated: true)
}
func pushClinic(clinicId: String) {
let vc = StoryboardScene.Clinic.initialScene.instantiate {
ClinicViewController(coder: $0, isHeaderEnabled: false)
}
let presenter = ClinicPresenter(view: vc)
presenter.setClinicId(clinicId: clinicId)
vc.inject(presenter: presenter)
viewController.navigationController?.pushViewController(vc, animated: true)
}
最後にコンポーネントの調整についてですが、CLINICS アプリではアトミックデザインを簡略化し、以下のような基準でコンポーネントを実装しています。
- Block: UI の最小単位 (他の PJT にも持ち込めそうなレベルまで分解されたもの)
- Partial: CLINICS の PJT 固有のコンポーネント
- Layout: ヘルプ用のモーダルやエラー画面など他の画面から呼び出されることで初めて意味をなす画面など
- Page: 各 ViewController とそれに紐づく Storyboard
この管理方法自体は 弊社の別プロダクトの開発を担当しているエンジニアによって考案されたものです。 弊社ではサーバー・フロント分け隔てなく開発を任されることも多く、厳密なアトミックデザインだとコンポーネントの分類に困ることが多かったためこのような管理方法をとっているそうです。 今回のリニューアルに際して CLINICS アプリでもこれを参考にすることにしました。
これまで DLS として管理されていたコンポーネントは Block で実装しなおし、Width / Height といったサイズの設定を行わないようにしました。
このようにコンポーネントの実装レベルを明確にしたことで、実装に一定の指針が生まれ使い回しの効くコンポーネントを作成しやすくなりました。
2. View とロジックの分離
スピード開発が求められた背景を考えると仕方のないことではあるのですが、従来の CLINICS アプリでは ViewController にロジックが記述されており、俗にいう Fat ViewController の状態になってしまっていました。
Fat ViewController の問題点については既にさまざまな方が取り上げていますが、以下の部分が問題と感じています。
- UI とロジックが分離されていないため、テストを書くことが難しい
- 可読性が低くなりがち
- ロジックが切り出されていないため似たような実装が点在する場合がある
課題
こちらは実際にあった ViewController の一部です。
このコードに関しては以下の部分が問題と感じていました。
- 通信処理の呼び出しが ViewController の責務になっていること
- 通信結果を整形するロジックが ViewController の責務になっていること
- 画面描画のための State 管理が ViewController の責務になっていること
解決案
UI とロジックを分離するための手法はさまざまなものがありますが、弊社のアプリには以下のような特徴があります。
- iOS 以外にも複数のプラットフォームをサポートしているためフロントエンドで保持するデータや複雑なロジック自体は少ない (サーバー側でなるべく担保している)
- 実装時や QA 時に感じた違和感について細かくディレクターと打ち合わせし、その結果次第では仕様を変更することがある
これらを考慮すると、プロジェクトの期間的に初期の導入コストが低く、データフローがシンプルなものに留めたいというように考えがまとまってきました。
これらを考慮し、CLINICS アプリでは MVP というアーキテクチャを採用することにしました。 より詳細には MVP の Passive View 方式を採用しており、以下のような形で実装しています。
- View は基本的にすべての入力イベントに対応した Presenter の処理を呼び出す
- Presenter は入力に応じて通信処理などの外部要因となる処理を呼び出し、結果を整形する
- プレゼンテーションロジックの結果を描画するように View に指示を出す
- View は Presenter の指示によってのみ描画処理を行い、自身を起点とした描画処理は行わない
Model–view–presenter - Wikipedia
既に Router は導入してるため、複雑な処理フローを実装したい場合は Interactor を導入するだけで VIPER アーキテクチャへと発展させることが簡単にできる点も魅力でした。
CLINICS アプリでの ViewController / Presenter の実装を簡単にまとめたものは以下のようになっています。
final class ClinicViewController: UIViewController {
private lazy var presenter: ClinicPresenterInput = {
fatalError(“Failed to inject presenter”)
}()
override func viewDidLoad() {
super.viewDidLoad()
presenter?.refresh()
}
func inject(presenter: ClinicPresenterInput) {
self.presenter = presenter
}
// タップされた際に呼び出す
func onTapServiceCell(_ service: ServiceEntity, isTelemedicine: Bool) {
presenter?.didTapServiceButton(service, isTelemedicine: isTelemedicine)
}
}
// MARK: - ClinicPresenterOutput
extension ClinicViewController: ClinicPresenterOutput {
func reloadData(clinic: ClinicEntity?)
{
clinicViewStore.clinic = clinic
}
func openCreateAppointmentView(clinic: ClinicEntity, service: ServiceEntity, isTelemedicine: Bool)
// 予約へ進む
}
func showErrorAlert(_ error: Error) {
// エラー表示を行う
}
}
protocol ClinicPresenterInput: AnyObject {
func refresh()
func didTapServiceButton(_ service: ServiceEntity, isTelemedicine: Bool)
}
protocol ClinicPresenterOutput: AnyObject {
func reloadData(clinic: ClinicEntity?)
func openCreateAppointmentView(clinic: ClinicEntity, service: ServiceEntity, isTelemedicine: Bool)
func showErrorAlert(_ error: Error)
}
final class ClinicPresenter {
private let clinicRepository: ClinicRepository
private var clinic: ClinicEntity?
private var cancellables: Set<AnyCancellable> = .init()
init(view: ClinicPresenterOutput, container: DIContainer) {
self.view = view
clinicRepository = container.resolve()
}
private func reloadView() {
view?.reloadData(clinic: clinic)
}
}
// MARK: - PresenterInput
extension ClinicPresenter: ClinicPresenterInput {
func refresh() {
guard let clinicId = clinicId else { return }
clinicRepository.getClinic(clinicId: clinicId)
.sink(
receiveCompletion: { [weak self] completion in
guard let self = self else { return }
guard case let .failure(error) = completion else { return }
self.view?.showErrorAlert(error)
}
},
receiveValue: { [weak self] response in
guard let self = self else { return }
self.clinic = response.data
self.reloadView()
}
)
.store(in: &cancellables)
}
func didTapServiceButton(_ service: ClinicsServiceEntity, isTelemedicine: Bool) {
guard let clinic = clinic else { return }
view?.openCreateAppointmentView(clinic: clinic, service: service, isTelemedicine: isTelemedicine)
}
}
ViewController と Presenter は以下のように Router の内部で DI するようにしています。
protocol ClinicWireframe: AnyObject {
var viewController: UIViewController { get }
func pushClinic(clinicId: String)
}
extension ClinicWireframe {
func pushClinic(clinicId: String) {
let vc = StoryboardScene.Clinic.initialScene.instantiate {
ClinicViewController(coder: $0, isHeaderEnabled: false)
}
let presenter = ClinicPresenter(view: vc, container: DIContainer())
presenter.setClinicId(clinicId: clinicId)
vc.inject(presenter: presenter)
viewController.navigationController?.pushViewController(vc, animated: true)
}
}
これによって、もともと ViewController にあった処理は以下のように分離されました。
また、View と Presenter はインターフェースを介してしかお互いを知らないため、実装の交換が簡単にできるようになりました。 Presenter を交換することで 1 つの View で別々の処理を表現することが可能となったり、View をテストコードを交換することで Presenter の入出力値をテストすることができるようになっています。
まとめ
CLINICS アプリは歴史のあるプロジェクトですが、今回のプロジェクトを通して UI だけでなく裏側の実装も刷新しています。 巨大な Storyboard を分解したことでメンテナビリティが向上し、MVP (+ Router) の導入によって View とロジックの交換が簡単になり、テストなどの実装を取り入れやすくなりました。
さいごに
ブログの本編には書けなかったのですが、今回リニューアルされた画面に関しては SwiftUI を利用してフルスクラッチで実装していたり、 Asset の管理方法についても大きな見直しを行いました。
またアーキテクチャの選定にあたり、「iOS アプリ設計パターン入門」が大変参考になりました。iOS の開発を行う方はぜひ一読してほしいと思います。
約半年ほどかかった大きなプロジェクトでしたが、患者エンゲージメントチームのメンバー全員で取り組み無事にリリースまで漕ぎ着けることができました。まだまだ手探りな部分もありますが、今後も患者さんにとってより安心して使えるサービスとなるように開発を続けていければと思っています。
長くなりましたが、最後までお読みいただきありがとうございました。