Medley Developer Blog

株式会社メドレーのエンジニア・デザイナーによるブログです

プラットフォームをまたぎブレない仕様を実現するための、ネイティブアプリ開発施策

こんにちは、開発本部の高井です。オンライン診療アプリ「CLINICS」のアプリ開発を主に担当しています。

CLINICSではWebに加えて、iOS版とAndroid版の各プラットフォームの仕様変更や機能追加などをほぼ同時に開発しているのですが、担当する人数が増えたりすることで、仕様に差が出たり、その結果手戻りが起きるということも増え始めていました。

そうした課題を解決するために実践した様々な施策の中から、特に有効だった3つの改善策について、今日はご紹介します。

背景

CLINICSの開発チームでは5人ほどのエンジニアがタスク単位で全てのプラットフォームを実装したり、大きいタスクの場合はプラットフォーム毎に別の開発者が担当する形で開発しています。

そのような形で機能追加や不具合対応の開発を進める中で、以下のような課題がありました。

  • プラットフォーム間で仕様やデザインが違う
  • リリース直前に仕様の違いが見つかり、手戻りが発生する
  • 各プラットフォームに対する習熟度にバラつきがあるため、開発者によって実装方法が違う

特にプラットフォーム毎に開発者がほぼ固定されてしまっていた時期には、コードレビューはしていても微妙な違いに気づかなかったり、同じUIにするのに実装コストが高くてあきらめたり、ということが起こりがちでした。 プラットフォーム間で仕様やデザインが違うとユーザ体験の質がプラットフォームによってバラついてしまいますし、デザインや企画の作業も増えてしまいます 。これらに加えて、エンジニアの人数が増えたり、デザイナーやカスタマーサポートなどエンジニア以外のメンバーとのコミュニケーションも増えたりしてきたこともあって、開発スピードも段々と遅くなってきていました。

そのような状況を改善するために、チーム内で継続的に実装方法や開発フローを見直し、改善策を実施してきました。

今回は以下の3つの改善策をご紹介します。具体的な実装については、主にiOSで使用しているコードを引用してご紹介します。(コードの一部を抜粋しているので、そのままでは使用することはできません。あくまでも参考コードとして読んでください。)

改善策1 DLS(デザイン言語システム)の導入

まずはDLS(デザイン言語システム)の導入についてです。DLSとは以前、本ブログでもデザイナーの前田がご紹介させていただきましたが(デザイン言語システムを入れたらコミュニケーションコストがぐっと下がった話〜メドレーTechLunch〜)、 UIに一貫性をもたせるため、配色やレイアウト、タイポグラフィやマージンなどのルール を策定し、チーム全体で継続的に運用していくための仕組みです。策定したルールを組み込んだ各コンポーネントのデザインを元に、Web / iOS / Androidの各プラットフォームでUIを実装して開発時に再利用できるようにしています。デザイン自体は下記のような形でSketchファイルで管理しています。

f:id:medley_inc:20180327164233p:plain

f:id:medley_inc:20180327164243p:plain

iOSについては各コンポーネントをカスタムビュークラスとして実装し、再利用できるようにしました。DLS導入以前はプラットフォーム毎に違ったUIやルールで開発していたので、実装段階で担当する開発者毎の認識によって品質や仕様に差が出ている状態でした。DLS導入によってそのような差が出にくくなり、一定の品質を保つことができるようになりました。 また、 UIの微調整などが減って、機能ロジックに重点を置いた開発に専念できるようになり、さらにデザイナーとの認識合わせが最小限になったことにより開発効率も上がった と感じています。UIの基盤をつくったことで新しく画面を開発する場合でもコンポーネントを組み合わせ、エンジニアだけで実装が完了することも多くなり、その分デザイナーは次の施策やプロジェクトに専念できるようになりました。

実装についてですが、各コンポーネント毎にxibファイルでUIパーツを作成し、それをクラスファイルで読み込んでカスタムビュークラスの見た目として使っています。カスタムビューは再利用しやすく、利用時にバラツキが出にくいように以下の点を満たすように実装しました。

  • Interface Builder/コードのどちらからでも初期化できる
  • ビルドする前にStoryboard上でUIパーツのデザインを確認できるようにIBDesignableとIBInspectableを指定する
  • カスタムビューの中でUI要素のマージンや高さを指定する

例えば、セレクトフォームコンポーネントのカスタムビューは以下のような実装になっています。

  • xibファイル f:id:medley_inc:20180327164320p:plain

  • クラスファイル

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を指定して配置すれば完了です。ユーザによるアクションのハンドリングや動的にプロパティを切り替える必要がある場合は、呼び出し側で処理を追加します。

f:id:medley_inc:20180327172653p:plain

最終的にビルドすると以下のように表示されます。(表示されている内容は開発中に作成した仮のデータで実際のものとは異なります。)

f:id:medley_inc:20180327172923p:plain

改善策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のチェックを通っているか

f:id:medley_inc:20180327164527p:plain

弊社が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

f:id:medley_inc:20180327164549p:plain

まとめ

CLINICSにおけるアプリ開発の品質と効率性を向上するための取り組みをご紹介しました。これらの取り組みによって プラットフォーム毎のデザインや機能のブレが少なくなり、認識ずれによる手戻りなどが少なくなったことで開発効率が上がった と感じます。プラットフォーム毎の違いを少なくして、より多くのメンバーがコードに手を入れやすい状態にすることで実装やコードレビューの質も向上しているように思います。

React Nativeなどを利用して、コードそのものを共通化する方法もあるとは思いますが、プラットフォーム毎に別のコードで開発する場合でも、仕様や実装のルールを工夫することでより効率的に開発できるのではないでしょうか。

CLINICSチームでは他にも実装や開発プロセス、プロダクト運用について日々改善を行なっています。今後も、こうした取り組みを積極的に実践し、KPT形式で振り返って、また次のアクションにつなげることで、多くの方に愛されるプロダクトを育てていきたいと思っています。

お知らせ

メドレーでは、エンジニアやデザイナーを募集しています。ご興味のある方は、こちらからどうぞ! www.medley.jp