プラットフォームをまたぎブレない仕様を実現するための、ネイティブアプリ開発施策
こんにちは、開発本部の高井です。オンライン診療アプリ「CLINICS」のアプリ開発を主に担当しています。
CLINICS では Web に加えて、iOS 版と Android 版の各プラットフォームの仕様変更や機能追加などをほぼ同時に開発しているのですが、担当する人数が増えたりすることで、仕様に差が出たり、その結果手戻りが起きるということも増え始めていました。
そうした課題を解決するために実践した様々な施策の中から、特に有効だった 3 つの改善策について、今日はご紹介します。
背景
CLINICS の開発チームでは 5 人ほどのエンジニアがタスク単位で全てのプラットフォームを実装したり、大きいタスクの場合はプラットフォーム毎に別の開発者が担当する形で開発しています。
そのような形で機能追加や不具合対応の開発を進める中で、以下のような課題がありました。
- プラットフォーム間で仕様やデザインが違う
- リリース直前に仕様の違いが見つかり、手戻りが発生する
- 各プラットフォームに対する習熟度にバラつきがあるため、開発者によって実装方法が違う
特にプラットフォーム毎に開発者がほぼ固定されてしまっていた時期には、コードレビューはしていても微妙な違いに気づかなかったり、同じ UI にするのに実装コストが高くてあきらめたり、ということが起こりがちでした。 プラットフォーム間で仕様やデザインが違うとユーザ体験の質がプラットフォームによってバラついてしまいますし、デザインや企画の作業も増えてしまいます 。これらに加えて、エンジニアの人数が増えたり、デザイナーやカスタマーサポートなどエンジニア以外のメンバーとのコミュニケーションも増えたりしてきたこともあって、開発スピードも段々と遅くなってきていました。
そのような状況を改善するために、チーム内で継続的に実装方法や開発フローを見直し、改善策を実施してきました。
今回は以下の 3 つの改善策をご紹介します。具体的な実装については、主に iOS で使用しているコードを引用してご紹介します。(コードの一部を抜粋しているので、そのままでは使用することはできません。あくまでも参考コードとして読んでください。)
- DLS(デザイン言語システム)の導入
- アプリエラーの共通化
- コードレビューの手順改善
改善策 1 DLS(デザイン言語システム)の導入
まずは DLS(デザイン言語システム)の導入についてです。DLS とは以前、本ブログでもデザイナーの前田がご紹介させていただきましたが(デザイン言語システムを入れたらコミュニケーションコストがぐっと下がった話〜メドレー TechLunch〜)、 UI に一貫性をもたせるため、配色やレイアウト、タイポグラフィやマージンなどのルール を策定し、チーム全体で継続的に運用していくための仕組みです。策定したルールを組み込んだ各コンポーネントのデザインを元に、Web / iOS / Android の各プラットフォームで UI を実装して開発時に再利用できるようにしています。デザイン自体は下記のような形で Sketch ファイルで管理しています。
iOS については各コンポーネントをカスタムビュークラスとして実装し、再利用できるようにしました。DLS 導入以前はプラットフォーム毎に違った UI やルールで開発していたので、実装段階で担当する開発者毎の認識によって品質や仕様に差が出ている状態でした。DLS 導入によってそのような差が出にくくなり、一定の品質を保つことができるようになりました。 また、 UI の微調整などが減って、機能ロジックに重点を置いた開発に専念できるようになり、さらにデザイナーとの認識合わせが最小限になったことにより開発効率も上がった と感じています。UI の基盤をつくったことで新しく画面を開発する場合でもコンポーネントを組み合わせ、エンジニアだけで実装が完了することも多くなり、その分デザイナーは次の施策やプロジェクトに専念できるようになりました。
実装についてですが、各コンポーネント毎に xib ファイルで UI パーツを作成し、それをクラスファイルで読み込んでカスタムビュークラスの見た目として使っています。カスタムビューは再利用しやすく、利用時にバラツキが出にくいように以下の点を満たすように実装しました。
- Interface Builder/コードのどちらからでも初期化できる
- ビルドする前に Storyboard 上で UI パーツのデザインを確認できるように IBDesignable と IBInspectable を指定する
- カスタムビューの中で UI 要素のマージンや高さを指定する
例えば、セレクトフォームコンポーネントのカスタムビューは以下のような実装になっています。
xib ファイル
クラスファイル
import UIKit
protocol ClinicsFormSelectDelegate: class {
func didClickFormSelect(sender: ClinicsFormSelect)
}
@IBDesignable class ClinicsFormSelect: UIView {
@IBOutlet weak var selectView: SelectView!
@IBInspectable var labelText: String = "Form-parts" {
didSet {
selectView.labelText = labelText
}
}
weak var delegate: ClinicsFormSelectDelegate?
// コードから初期化する場合に呼ばれる
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
// Interface Builder から初期化する場合に呼ばれる
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
private func commonInit() {
// xib ファイルの読み込み
let bundle = Bundle(for: type(of: self))
let view = UINib(nibName: "ClinicsFormSelect", bundle: bundle).instantiate(withOwner: self, options: nil).first as! UIView
addSubview(view)
backgroundColor = .clear
view.backgroundColor = .clear
// 読み込んだ View のサイズがカスタムクラス(ClinicsFormSelect)と同じサイズになるように Constraint を設定する
view.translatesAutoresizingMaskIntoConstraints = false
let bindings = ["view": view]
addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|",
options:NSLayoutFormatOptions(rawValue: 0),
metrics:nil,
views: bindings))
addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|",
options:NSLayoutFormatOptions(rawValue: 0),
metrics:nil,
views: bindings))
}
// xib ファイルの中に配置した UI 要素へのアクションのハンドリング
@IBAction func didTap(_ sender: UITapGestureRecognizer) {
delegate?.didClickFormSelect(sender: self)
}
}
CLINICS では主に Storyboard を使って UI を実装しているので、使用するときは Storyboard に UIView を置き、コンポーネントのクラス名を指定して使います。テキストなどのプロパティを設定し、Constraint を指定して配置すれば完了です。ユーザによるアクションのハンドリングや動的にプロパティを切り替える必要がある場合は、呼び出し側で処理を追加します。
最終的にビルドすると以下のように表示されます。(表示されている内容は開発中に作成した仮のデータで実際のものとは異なります。)
改善策 2 アプリエラーの共通化
以前は業務的に重要な処理のエラー以外はプラットフォーム毎で表示するエラーメッセージが異なっていたり、エラーハンドリング時に違った挙動をしていることがありました。その結果、ユーザ体験が一貫したものになっていないというだけでなく、お問い合わせがあってもカスタマーサポートが一次回答しにくかったり、伝えられた内容が曖昧なため開発者が調査するのに時間がかかったりすることがありました。
そこで、改めてフロント側で発生するエラーの定義を共通化し、エラーメッセージやエラーハンドリング時の処理も統一しました。 問い合わせの効率化のために共通のエラーコードも決めて、エラー発生時に表示されるアラートに追加し、それらのエラー定義はドキュメントで一覧化して、カスタマーサポートにも共有 するようにしました。
また、エラーハンドリング時にクラッシュレポートのログに記録する内容や送信するタイミングを統一して、開発者全員が理解しやすいようにしました。エラーコードの表示については、改善を検討していた時期にちょうど参加していた iOSDC Japan 2017 で、同じような課題に対する知見を発表されていたのを見て、早速取り入れました。最近ではユーザからの問い合わせにもエラーコードが使われることがあり、実際にコミュニケーションコストを低下させることができているように思います。
エラーのフィードバックは細かいところではありますが、ユーザのアクションを継続させるために重要な要素のひとつです。CLINICS はユーザ属性が老若男女問わず幅広いので特に気を配って改善を行ってきました。 実装についてですが、iOS では以下のように定義しています。
enum ApplicationError: Error {
case commonRequestError(String)
case createReservationCardError
case createReservationScheduleIsFullError
~
var errorCode: String {
switch self {
case let .commonRequestError(viewId):
return "\(viewId)-0000"
case .createReservationCardError:
return "40-0001"
case .createReservationScheduleIsFullError:
return "40-0002"
~
var title: String {
switch self {
case .commonRequestError:
return "接続エラー"
case .createReservationCardError:
return "決済失敗エラー"
~
var description: String {
switch self {
case .commonRequestError:
return "データを正しく表示出来ない可能性があります。\n 通信状況をお確かめいただくか、しばらく経ってから再度起動してください。"
case .createReservationCardError:
return "ご登録されているクレジットカードの決済中にエラーが発生しました。\n おそれいりますが、もう一度最初から操作ください。"
~
Android でも同様に enum で定義しています。
enum class ApplicationError(var code: String, val title: String, val description: String) {
CommonRequestError("0000", "接続エラー", "データを正しく表示出来ない可能性があります。\n 通信状況をお確かめいただくか、しばらく経ってから再度起動してください。"),
CreateReservationCardError("40-0001", "決済失敗エラー", "ご登録されているクレジットカードの決済中にエラーが発生しました。\n おそれいりますが、もう一度最初から操作ください。"),
CreateReservationScheduleIsFullError("40-0002", "スケジュール空きなしエラー", "選択された予約日時のスケジュールに空きがありませんでした。\n おそれいりますが、別の予約日時をご選択のうえ、もう一度最初から操作ください。"),
~
改善策 3 コードレビューの手順改善
リリース当初から実装者以外のメンバーによるレビューは適宜行なっていましたが、レビューの段階でデグレや仕様の違いを見逃してしまうことがあったので、レビュー体制の強化とメンバーのソース理解の向上を図るために、以下のようにルールを設定しました。
- セルフマージはしない
- PR に対して 2 人以上でレビューする
- ビューの変更があった場合には画面キャプチャを貼る
それらを守りやすく、より効率的にするためにDangerも導入しました。 導入手順はこちらにまとめられているほか、検索すればけっこう出てくるので省略します。弊社では iOS の CI は Bitrise を使用しているので Bitrise 上で実行して GitHub の PR に反映させています。
Danger では、以下の項目をチェックしています。上記のルールを反映しているのに加えて、PR の向き先と SwiftLint の実行結果もチェックしています。CLINICS の iOS アプリでは GitFlow を導入しているため、release ブランチと hot-fix ブランチ以外からの PR の向き先が develop ブランチになっていない場合には警告を出すようにしています。
- レビュアーの人数が 2 人以上になっているか
- ビューの変更(xib、storyboard を触ったかどうかのみ確認)があった場合に画面キャプチャを貼っているかどうか
- PR が develop に向けて作成されているか
- SwiftLint のチェックを通っているか
弊社が iOS 開発で利用している Danger ファイルは以下の通りとなっています。導入する際のご参考にしてください。
# for only difference
github.dismiss_out_of_range_messages
# reviewers
warn("レビュアーは 2 人以上指定してください") if github.github.pr_json["requested_reviewers"].length < 2
# view changes
view_extensions = [".xib", ".storyboard"]
has_view_changes = git.modified_files.any? { |file| view_extensions.any? { |ext| file.end_with? ext }}
has_view_added = git.added_files.any? { |file| view_extensions.any? { |ext| file.end_with? ext }}
pr_has_screenshot = github.pr_body =~ /https?:\/\/\S*\.(png|jpg|jpeg|gif){1}/
warn("見た目に変更がある場合は画面キャプチャを貼ってください") if (has_view_changes or has_view_added) and !pr_has_screenshot
# base branch
is_to_master = github.branch_for_base == 'master'
is_to_develop = github.branch_for_base == 'develop'
is_from_releases = !!github.branch_for_head.match(/releases\/[0-9]+\.[0-9]+\.[0-9]/)
warn('PR は develop に向けてください') if !is_to_develop and !(is_from_releases and is_to_master)
# swiftLint
swiftlint.lint_files inline_mode: true
まとめ
CLINICS におけるアプリ開発の品質と効率性を向上するための取り組みをご紹介しました。これらの取り組みによってプラットフォーム毎のデザインや機能のブレが少なくなり、認識ずれによる手戻りなどが少なくなったことで開発効率が上がったと感じます。プラットフォーム毎の違いを少なくして、より多くのメンバーがコードに手を入れやすい状態にすることで実装やコードレビューの質も向上しているように思います。
React Native などを利用して、コードそのものを共通化する方法もあるとは思いますが、プラットフォーム毎に別のコードで開発する場合でも、仕様や実装のルールを工夫することでより効率的に開発できるのではないでしょうか。
CLINICS チームでは他にも実装や開発プロセス、プロダクト運用について日々改善を行なっています。今後も、こうした取り組みを積極的に実践し、KPT 形式で振り返って、また次のアクションにつなげることで、多くの方に愛されるプロダクトを育てていきたいと思っています。
お知らせ
メドレーでは、エンジニアやデザイナーを募集しています。ご興味のある方は、こちらからどうぞ!
https://www.medley.jp/recruit/creative.html