トレタ開発者ブログ

飲食店向け予約/顧客台帳サービス「トレタ」、モバイルオーダー「トレタO/X」などを運営するトレタの開発メンバーによるブログです。

台帳アプリのカラーリファクタリングをした話

こんにちは。
サンタ業務を控えているパパiOSエンジニアのkentaroです。

今年実施したトレタ iPad台帳のカラーリファクタリング(v35.0にて対応しました)についてご紹介します。

なぜ行ったのか

これは担当デザイナーの言葉をそのまま引用します。

トレタはリリースされてから5年、デザインが大きく変わることはありませんでしたが、機能追加で画面が増えていく過程で、微妙な差の色も含めて120色も使用されていました。
色数が多いと下記のような問題が発生します。

  • プロダクト内の統一感がない
  • どの色を使用すればいいか考えなくてはいけないので、デザイナーの作業工数がかかる

今回は2つ目の業務効率化をメイン目標にリファクタリングしています。

どう変わったか

ホーム画面

キーカラーを少しだけ濃くして白文字の視認性を上げています

f:id:kenkenken_3:20191224155827p:plain

予約フロー画面

背景に埋もれがちだったテキストのコントラストを上げて視認性を上げました
フッターも装飾を少なくし、入力を邪魔しないよう修正しています

f:id:kenkenken_3:20191224155856p:plain

タイムテーブル・テーブルレイアウト

背景に埋もれがちだったテーブルレイアウトのステータスのコントラストがはっきりするよう調整しました

f:id:kenkenken_3:20191224155916p:plain

自分が担当したこと

新しい色の管理方法やソースコード上での利用方法の設計提案、実装の補助(困ったときに相談してもらう)、ソースコードレビューを行いました。

デザイン担当

date001 (去年のアドベントカレンダーにも登場いただきました)感謝!

note.com

去年のアドベントカレンダー

tech.toreta.in

実装担当

実際に手を動かしてくれたのは CSからエンジニアにジョブチェンジしました でおなじみの@yukimura03です。感謝!

tech.toreta.in

QA 担当

三度の飯より寿司が好きでおなじみのQAエンジニアの林です。感謝!

tech.toreta.in

実装について

実装について紹介していきます。

色の定義

Asset Catalogに定義しました。
これによりソースコード上でもIB(Interface Builder)上でも名前をkeyにして同じ色が扱えるようになりました。
ある名前の色を少し調整したい、というときもAsset Catalogを編集するだけですみ、変更にも強くなりました。

ソースコード上で使いやすくする工夫

色名を表す EnumUIColorExtension を用意し、Enumcase を渡して UIColor を取得できるようにしました。

/// Assetsに定義した色を表すEnum
@objc enum ColorPalette: Int {

    case green500
    case green400
    case green300
    case green200
    case green100
    case green50

    // その他の色も定義していく

    // MARK: - Property

    /// Assetsで定義した色名を返す
    ///
    /// ObjcでrawValueが使えないので暫定
    /// トレタで実際に利用している命名とは異なります
    var rawValueString: String {
        switch self {
        case .green500: return "green-500"
        case .green400: return "green-400"
        case .green300: return "green-300"
        case .green200: return "green-200"
        case .green100: return "green-100"
        case .green50:  return "green-50"
        }
    }
}
extension UIColor {

    /// カラーパレットに対応するUIColorを生成する
    ///
    /// - Parameter palette: ColorPalette
    @objc convenience init(_ palette: ColorPalette) {

        // ここで落ちたらColorPaletteかColorSetに定義ミスがある
        self.init(named: palette.rawValueString)!
    }
}

呼び出し

// Swift
let green50 = UIColor(.green50)
// Objective-C
UIColor *green50 = [[UIColor alloc] init:ColorPaletteGreen50];

終わりに

カラーリファクタリングはiOSエンジニアだけではなくデザイナー・QA等様々なロールの仲間と取り組んでおります。
その中で「すでにカッチリ決まった仕様を実装していく」よりも「早い段階から複数職種が議論して作り上げていく」ほうが性に合っていると感じました。

同じような考えをお持ちの方にはトレタと相性がいい可能性高いと思いますので、是非遊びに来てください!
立ち呑みトレタ という交流イベントもやっています。
前回のお知らせはこんな感じです。
次回は1月下旬頃との噂。詳細決まり次第お知らせ出るかと思いますので興味のある方は是非お越しください!

www.wantedly.com

SwiftUIとCombineを使ったMVVMの実装

この記事は トレタ Advent Calendar 2019 の23日目の記事です。

こんにちは。iOS & Androidエンジニアの山口です。 トレタでは、主にトレタnowの開発を行っています。

これまで、UIKit、RxSwift(RxCocoa)を使ったMVVMアーキテクチャで実装することが多かったこともあり、 SwiftUI、Combineを使った場合にはどう書けばよいか考えてみました。

なお、この記事はSwiftUIを使う際に重要なポイントとなる PropertyWrappersやDynamicMemberLookup、それらを利用したObservedObjectやBindingについて の知識がある前提で記述しています。

ViewModel Protocol の定義

早速ですが、コードから説明していきます。 まず、すべてのViewModelが準拠すべきシンプルなProtocolを定義しました。 意図としては下記2点が挙げられます。

  • チーム開発する上でのViewModelの書き方をある程度統一したい
  • SwiftUI側で利用しやすい形にしたい
/// ViewModelが準拠するプロトコル
protocol ViewModelObject: ObservableObject {

    associatedtype Input: InputObject
    associatedtype Binding: BindingObject
    associatedtype Output: OutputObject

    var input: Input { get }
    var binding: Binding { get }
    var output: Output { get }

}

extension ViewModelObject where Binding.ObjectWillChangePublisher == ObservableObjectPublisher, Output.ObjectWillChangePublisher == ObservableObjectPublisher {

    var objectWillChange: AnyPublisher<Void, Never> {
        return Publishers.Merge(binding.objectWillChange, output.objectWillChange).eraseToAnyPublisher()
    }

}

protocol InputObject: AnyObject {
}

protocol BindingObject: ObservableObject {
}

protocol OutputObject: ObservableObject {
}

@propertyWrapper struct BindableObject<T: BindingObject> {

    @dynamicMemberLookup struct Wrapper {
        fileprivate let binding: T
        subscript<Value>(dynamicMember keyPath: ReferenceWritableKeyPath<T, Value>) -> Binding<Value> {
            return .init(
                get: { self.binding[keyPath: keyPath] },
                set: { self.binding[keyPath: keyPath] = $0 }
            )
        }
    }

    var wrappedValue: T

    var projectedValue: Wrapper {
        return Wrapper(binding: wrappedValue)
    }

}
ViewModelObject
  • 後述するInputObject、BindingObject、OutputObjectをプロパティとして持つ
  • 値の変更をSwiftUIに通知するためにObservableObjectに準拠
InputObject
  • ユーザからの入力であるタップなどのイベント一覧をPassthroughSubjectとして定義
BindingObject
  • TextFieldの文字列やToggleのオン・オフなどのような双方向バインディングする値の一覧を@Publishedとして定義
  • 値の変更をSwiftUIに通知するためにObservableObjectに準拠
OutputObject
  • Textに表示する文字列やListに表示する配列などUIに出力する値の一覧を@Publishedとして定義
  • 値の変更をSwiftUIに通知するためにObservableObjectに準拠

SwiftUIからViewModelを使うための工夫

SwiftUI側はViewModelインスタンスを@ObservedObjectとして保持します。 ViewModelが持つBindingObjectOutputObjectの値が更新された際に再描画させたいので、 その2つのobjectWillChangeをマージした結果をViewModelObjectobjectWillChangeとしています。

また、BindingObjectから双方向バインディングするためのBindingを取り出すために、 @propertyWrapper@dynamicMemberLookupを利用しています。

MVVMの実装

では実際にViewModelObjectを利用して、サインアップ画面ぽいものを実装してみます。 UIの構成要素は下記の通りです。

  • ID入力欄(6文字以上)
  • パスワード入力欄(8文字以上)
  • 確認用パスワード入力欄
  • 規約同意のトグルスイッチ
  • サインアップボタン

f:id:kyo__hei:20191224104341p:plain

ViewModelの実装

final class SignUpViewModel: ViewModelObject {

    final class Input: InputObject {
        /// サインインボタンのタップイベント
        let signUpTapped = PassthroughSubject<Void, Never>()
    }
    
    final class Binding: BindingObject {
        /// ユーザID
        @Published var id = ""
        
        /// パスワード
        @Published var password = ""
        
        /// 確認用パスワード
        @Published var confirmPassword = ""
        
        /// 利用規約同意フラグ
        @Published var agreed = false
        
        /// サインアップ完了アラート表示フラグ
        @Published var isCompletionAlertPresented = false
    }
    
    final class Output: OutputObject {
        /// サインアップボタンの有効フラグ
        @Published fileprivate(set) var isSignUpEnabled = false
    }
    
    let input: Input
    
    @BindableObject private(set) var binding: Binding
    
    let output: Output
    
    private var cancellables = Set<AnyCancellable>()
    

    init() {
        let input = Input()
        let binding = Binding()
        let output = Output()
        
        /// ユーザIDのバリデーション(6文字以上)
        let isIdValid = binding.$id
            .map { $0.count >= 6 }

        /// パスワードのバリデーション(6文字以上)
        let isPasswordValid = binding.$password
            .map { $0.count >= 8 }

        /// 確認用パスワードのバリデーション(`password`と一致)
        let isConfirmPasswordValid = Publishers
            .CombineLatest(
                binding.$password,
                binding.$confirmPassword
            )
            .map { $0.0 == $0.1 }
        
        /// サインアップボタン有効フラグ
        ///   - すべての入力内容が有効
        ///   - 利用規約に同意
        let isSignUpEnabled = Publishers
            .CombineLatest4(
                isIdValid,
                isPasswordValid,
                isConfirmPasswordValid,
                binding.$agreed
            )
            .map { $0.0 && $0.1 && $0.2 && $0.3 }
        
        /// サインアップ完了アラートの表示
        let isCompletionAlertPresented = input.signUpTapped
            .flatMap {
                // 実際にはModel層のサインアップ処理を呼び出す
                Just(true)
            }
        
        
        // 組み立てたストリームをbinding, outputに反映
        cancellables.formUnion([
            isCompletionAlertPresented.assign(to: \.isCompletionAlertPresented, on: binding),
            isSignUpEnabled.assign(to: \.isSignUpEnabled, on: output)
        ])
        
        self.input = input
        self.binding = binding
        self.output = output
    }

}

ViewModelの実装は基本的にイニシャライザのみで、InputObjectBindingObjectから流れてくるストリームの合成を行い、 その結果をBindingObjectOutputObjectに反映させています。BindingObjectに定義されている双方向バインディング用の値は@Publishedとして定義されているため、 $をつけることでPublisherを取り出すことができます。

Viewの実装

import Combine
import SwiftUI

struct SignUpView: View {
    
    @ObservedObject var viewModel: SignUpViewModel
    
    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            TextField("ユーザID", text: viewModel.$binding.id)
                .textFieldStyle(RoundedBorderTextFieldStyle())
            
            TextField("パスワード", text: viewModel.$binding.password)
                .textFieldStyle(RoundedBorderTextFieldStyle())
            
            TextField("パスワード(確認用)", text: viewModel.$binding.confirmPassword)
                .textFieldStyle(RoundedBorderTextFieldStyle())
            
            Toggle(isOn: viewModel.$binding.agreed) {
                Text("利用規約に同意する")
            }
            
            Color.clear.frame(height: 16)
            
            Button(action: { self.viewModel.input.signUpTapped.send(()) }) {
                HStack {
                    Spacer()
                    Text("サインアップ")
                        .padding(.vertical)
                    Spacer()
                }
            }
            .disabled(!viewModel.output.isSignUpEnabled)
            
            Spacer()
        }
        .padding(.horizontal)
        .padding([.top], 64)
        .alert(isPresented: viewModel.$binding.isCompletionAlertPresented) {
            Alert(title: Text("サインアップ完了"), dismissButton: .cancel(Text("OK")))
        }
    }
    
}

@ObservedObjectとしてViewModelのプロパティを定義します。 ボタンタップなどのアクションを受け取るクロージャで、InputObjectSubjectに対してイベントを送信しています。 TextFieldなどの双方向バインディングする値については、viewModel.$bindingと書くことでBindableObject.Wrapperを取り出すことができ、 DynamicMemberLookupによりBindingプロパティを取得しています。

おわりに

SwiftUIにはバインディングの仕組みが標準的に実装されており、UIKitとRxSwift(RxCocoa)での実装にくらべ、かなりすっきりと書くことができました。 また、今回実装したViewModelはView側をUIKitに差し替えても使うことができるため(バインディングの仕組みが無いので少しつらいですが)、 SwiftUIではまだ実装が厳しい部分については、一部UIKitで実装するなどの選択肢も取ることが可能かと思います。

Combineについては、2019年12月時点ではWithLatestFromオペレータが無いなどの問題はありますが、 RxSwiftを触ったことがあればすんなり実装できるかと思います。 SwiftUIとCombineのこれからに期待しています!

なぜQAエンジニアが仕様を書いたのか

この記事はトレタ Advent Calendar 2019 21日目の記事です。

こんにちは。QAエンジニアの坂田です。

トレタに入社して1年が経ち、既存サービスのエンハンス開発やいくつかのリニューアルプロジェクトに携わってきました。プロダクト品質向上やメンバーへの貢献を模索するなかでQAエンジニアが仕様を書くという試みをテーマの1つとして行ってきましたので、今回はその話をしたいと思います。

なお、本記事では「プロダクトの外部的な振る舞いを定義したもの(UI仕様)」のことを「仕様」と呼びます。テストの仕様ではありませんのでご注意ください!

そもそもなぜ仕様を書くのか

仕様を書く目的を私なりにざっくり表現すると、以下のような感じになります。

  • リリース前:プロダクト設計の不明瞭さや矛盾を早期検知・解決するため
  • リリース後:プロダクトの振る舞いが本来どうなっている(べき)か確認するため

リリース前は開発フェーズ、リリース後は運用フェーズと言い換えると分かりやすいかもしれません。

仕様はメンテが大変、すぐ陳腐化する、などの理由でそもそも書かれないことも多いと思いますが、それはリリース前ではなく後者(リリース後)のドキュメント保守の不毛さが原因だと考えています。

最近ではソースコードやテストケースなどの最終成果物で仕様を分かりやすく表現し、中間的な仕様書の保守をなくしてしまおう、という考えも主流になっています。 リリースを経て最終成果物が揃っている段階であれば、個人的にはこの考え方に大賛成です。

ここで問題なのは、前者と後者が混同され、保守が大変だからという理由で前者(リリース前)の目的での仕様作成も行わない判断が下されがちなことです。

リリース前は、要件検討〜UI設計〜実装〜テストの全てのフェーズで設計を反復的に更新する必要が生じます。 また、後ろのフェーズで不明瞭さや矛盾が見つかれば、都度前のフェーズへ戻って設計を見直すことになります。

そのため開発フェーズにおいては、可能な限り早い段階から設計の問題点を解消しつつ、プロジェクト全体に及ぶ頻繁な設計変更に追随し、メンバー間で常に認識を揃えるための何か(つまり仕様)が必要です。

個人的な考えですが、開発フェーズでは「プロダクト設計の不明瞭さや矛盾を早期検知・解決する」ために仕様を書くのは意義があるし、むしろそれに特化させてしまって良いのではないか、と考えています。

なぜQAエンジニアが書くのか

通常、QAが仕様をレビューすることはあっても、書くことはあまりないかもしれません。 にも関わらず私が仕様を書こうと思ったのは、以下のように着手しやすい状況だった、というのが大きいです。

  • ゼロからではなく既存プロダクトの動作をベースに仕様を書き起こすことができた(リニューアルプロジェクトの場合)
  • プロジェクト参入時点で画面デザインや遷移図などのドキュメントが揃っていた
  • キャッチアップのための時間的余裕があった

実際に仕様を書いてみて、QAが仕様を書くのは自分のためにもチームのためにも利点が多いと実感しました。 具体的にいくつか挙げてみます。

仕様作成へのモチベーションがある

困ったことに、そもそも仕様(あるべき振る舞い)が分からないとQAはテストを設計できません。 そうした状況を日頃から経験しているQAは、テストに足りない情報を集めて仕様を整理する、という行動を元々取ってきており、それをプロジェクト早期から行っておくことへの強いモチベーションがあります。

早期に第三者視点で仕様をチェックすることで品質が上がる

個人的に一番重要と考えているのがこれです。 実はテストで見つかるバグのほとんどは実装の不手際ではなく、重要な要件に誰も気づいていなかったり、仕様に綻びがあったり、仕様の認識がずれていたことが原因だったりします。

ソフトウェアテストには「初期テスト」という原則があり、プロジェクト終盤のテストでバグを見つけるよりも序盤で設計の問題点を早く潰した方が何倍何十倍も少ないコストで対処できます。

QAは通常テストを行うことで品質をチェックしますが、むしろより重要なのは、どれだけ早期に仕様の不明瞭さや矛盾を沢山発見し解決に導いていけるかだと考えています。

その意味で、QAがテストに使える粒度でちゃんと仕様を書き、同時に仕様作成のベースとなるドキュメントを第三者視点でチェックする役割を持つ意義は大きいです。

実装フェーズやテストフェーズで本来のタスクに集中できる

仕様が書かれない場合、実装フェーズでデベロッパが細かい仕様を検討しながらコードを書いていくことになります。 その結果、デベロッパはシステムの内部構造やアルゴリズムの検討など本来のタスクに集中できなくなり、外部の振る舞いまで扱う必要が生まれ、複雑度は格段に上がってしまいます。

仕様策定作業を後工程に丸投げせず先に行っておくことで、各フェーズで本来のタスクに集中できるようになり、開発スピードも向上します。

デザインから開発への橋渡しができる

QAは元々テスト設計などで「ユーザに対して果たすべき機能」を「システムが行うべき振る舞い」として表現する能力を身につけています。

これはまさに仕様を書く際にも発揮される能力で、システム構造や内部処理を念頭に置きながらデザインで意図された機能や振る舞いを書いていくことで、必要な情報を開発に必要な粒度で漏れなく記載することができます。

デベロッパにちゃんとテストを書いてもらえる

期待する動作が仕様で整理されているとデベロッパがユニットテストや結合テストを書きやすくなり、実際に書いてもらえる、という利点もあります。

仕様を書いてみて学んだこと

最後に、QAエンジニアが仕様を書いてみて学んだことと反省点を整理します。

  • 良かった点
    • 仕様を細部まで書こうとすることで初めてデザインや仕様の不明瞭さや矛盾点を発見できることが多かった
    • 仕様を書いている途中で色々とテスト観点が浮かび、それをテストフェーズに活かせた
    • プロジェクトのスムーズな進行や品質向上に多少なり貢献でき、PMやデベロッパから感謝された
  • 反省点
    • 仕様を詳細に書いたつもりでも、仕様漏れによる手戻りがいくつか発生してしまった
    • QAのプロジェクト参画時期が、仕様を書くには少し遅いタイミングだった
    • プロジェクトメンバーに仕様を読んでもらう努力が足りず、仕様が認識されていないことがあった
    • プロジェクト完了まで仕様書をちゃんと更新し続ける意識と覚悟が必要

次にやりたいこと

テーマは変わりますが、E2EテストレベルでBDD(ATDD)を実践していきたいです!

一緒に働く仲間を探しています

トレタのQAエンジニアはお互いの自主性を尊重しつつ、プロダクト品質向上・生産性向上のために積極的に幅広い役割を担っていくことが可能です。

SET(Software Engineer in Test)も求めていますので、ご興味ある方はぜひ下記からご応募ください!

© Toreta, Inc.

Powered by Hatena Blog