トレタ開発者ブログ

飲食店向け予約/顧客台帳サービス「トレタ」、超直前予約アプリ「トレタnow」を開発・提供するスタートアップ企業です。

トレタ開発組織振り返り 2019年版

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

こんにちは。最近、子供に荷物に買い物かばんにとリアルポーターごっこに腰がつらくなってきた@joy04dです。

前回のAdvent Calendarでこんな事を書いていた自分ですが、その後、今年1月から VP of Engineering の役割を頂いております。まだ1年足らずのひよっこですが、日々頑張ってお仕事しております(=ω=)

今回の記事では、せっかくなのでこの1年の振り返りと知見(あるいはアンチパターン)の共有を兼ねて、つらつらと*2書いてみたいと思います。

どんなことしたか

今年のコンセプトを一言でいうと 個からチームへ になります。

トレタの開発組織では、創業期から 自治 をコンセプトとしており、フラットな組織のなかでエンジニアそれぞれが自身の裁量と責任を持って行動することを大事にしていました (この辺りで軽く触れられています)

これは個の力を全面に押し出すアプローチであり、創業期の小規模組織などにおいては理想的に働く一方で、今のトレタのように100人を超える組織規模では、様々な面でデメリットが目立つようになってきていました。対策としてプレイングマネージャー的な人を置いてみたりもしたのですが、皆がゴリゴリと手を動かさなくては行けない状況では、なかなか腰を据えてそちらに力を入れるのも難しく、組織のコンディションも徐々に悪化する形となってしまっていました。

そこで改めて専門職としてのマネージャーを置き、皆が効率的に動ける体制を作ろう、個人の頑張り依存ではなくチームで組織的に課題に向かえる体制を作ろう、と気合を入れたのが今年の流れになります(>∀<)

やったこと

ということで、今年の動きをつらつらと書いてみます。
地味です( ´_ゝ`)


採用編

課題

  • 事業や組織の成長にエンジニアの採用が追いついておらず、常に人手不足
    • ベンチャー企業の必然ではある
  • 即戦力を求めすぎて、間口が狭い。また組織として同質化が進みすぎる
    • 組織の成長には適度な多様さが大事

対策

  • 会える機会を増やす
    • 採用は人と組織のマッチングであり、接触機会が多いほど求める人(あるいは組織)に出会いやすい
      • 巡り合わせ大事
    • 面接フローのスリム化・レイヤー化
      • これまではできる限り全員で判断する方針
        • 面接回数が多く、双方にとっての負荷が大きい。
          • 6回面接したことも
        • 全員が採用に責任を持つ ≒ 誰も責任を持っていない状態になりがち
          • 各々が求めるすべてにマッチする人はそういない
      • これをエンジニアによる技術面接とマネージャーによる組織面接に分割
        • 誰がどの観点に責任を持って見るのかはっきりと
          • それぞれの立場からの擦り合わせも大事に
  • ヒット率を上げる
    • リファラル採用の強化
      • 自分の会社にマッチする人は中の人が一番知っている
      • エンジニアに限らず社員総出で頑張る
  • 自分たちをもっと知ってもらう
    • これも機会が大事

結果

  • エンジニアの人数が倍になった。ヤッタゼ
  • ジョインされた方々もすぐにvalueを発揮、楽しく仕事をしていただいている。ヤッタゼ

反省点

  • 急激に人が増えたために、オンボーディングやコードレビューのコストが大変なことに
    • 裾野が広がるより早く人が増えたために一極集中化
      • 古株の方々、特に @m_nakamura145 の頑張りでどうにか乗り切れたところが大きい。正直タスカッタ
    • 組織の拡大と地盤固めのバランス大事

組織編

課題

  • エンジニアを共有資源的に捉え、あちらこちらのプロダクトで隙間なくタスクをこなす状態
    • キツキツパイプライン問題
      • 人手不足もありタスクが同時進行となることも多いが、人のコアはひとつでスイッチングコスト大
      • 1つのプロダクトの影響が他のすべてに伝搬するため、スケジュールの不確実性大
        • 優先度の判断も難しい
      • 隣の人が何やってるかわからない状態になりがちで、ノウハウの継承がされにくい
    • 傭兵化問題
      • 腰を据えて1つのプロダクトに向き合う時間が取りにくいため、関わりが薄くなりがち
        • 目標(と評価)の軸も定めにくく、どうしても受け身寄りの動きとなる
      • 常に同じ技術領域で呼ばれるため、新たな技術領域に挑戦する機会を作りにくい
      • 継続的な保守・改善や技術的負債の返済に時間を割きにくい
        • 各々の隙間で頑張り対応だが、徐々に追いつかずに

対策

  • マトリクス型組織への移行
  • 構成イメージ
    f:id:joy04d:20191225141431p:plain
    • 縦軸に事業戦略の軸に合わせた開発チーム、横軸に横断的に動く基盤チーム
        • 開発チーム → Toreta nowチーム
        • 基盤チーム → SREチーム
          • ちなみにSREチームは今回新設
            • 攻めの運用を目指す
      • チーム単位でミッションを持つ
        • 縦 or 横 → チームが効率的に動きやすい方向を主軸に
    • 各チームにリーダーを設定
      • チームのミッション達成に責任を持つ
      • ある程度チーム体制が固まるまではマネージャーが兼務で乗り切る
        • 何もないところからいきなりリーダーをやってくれといわれても厳しい
    • 技術的な横断役として各領域ごとにテックリードを設定
      • 技術的なよろずサポート・レビューから中長期的な技術戦略などを担当
  • 事業・組織・個人の3軸の成長に目標を設定
    • 事業 → Issue First
    • 組織 → Respect All
    • 個人 → Dive!
    • に合わせた形。
      • 三方良し
      • 元々の自治の流れを失わないように
        • 人によりアプローチは様々で、特に組織・個人軸はその人次第
          • みんなで幸せになろうよ

結果

  • チームで集中して開発が行えるようになった。スケジュールの確実性も上がった。ヤッタゼ
  • 改善などに向けた主体的なアプローチがしやすくなり、個人・プロダクトともに成長機会が増えた。ヤッタゼ
  • チーム内でノウハウの共有が活発化した。ヤッタゼ
  • テックトークなどの横断的な社内勉強会も活発化。ヤッタゼ
    • 開発者ブログも活発化。ヤッタゼ
      • (AdventCalendar効果だけど)

反省点

  • マネージャー集中問題
    • チームリーダー兼務はチーム体制が固まるまでの繋ぎだったが、早々にオーバーフロー
      • 当初の想定よりもだいぶ早く各チームリーダーにバトンタッチ
        • 割とぶん投げになってしまったが、皆に助けられていい感じになった。正直タスカッタ
      • 人にちゃんと仕事を任せよう
        • 自分のキャパも考えよう
  • チームの柔軟性問題
    • トレタのようなベンチャーでは短い期間で状況が変わることもある
    • その変化に対してチーム構成が固くなりすぎて、運用でカバーなところが出てきてしまっていた
      • チーム間のアサイン問題など
      • バックオフィスのツールとの兼ね合いもある
    • どんな体制も柔軟に運用できる遊びが大事
      • 準備不足・ω・

技術編

課題

  • モノリシックなアーキテクチャ
    • そろそろ分割を考えたい規模感
  • Go入門
    • トレタといえばRubyだが、新しいプロダクトの一部にはGoを採用している
      • 適材適所。今後は2本柱に
    • が、やはり6年の経験があるRubyに比べるとノウハウ不足は否めない

対策

  • テックリード主体で次世代のアーキテクチャを議論
  • Goの技術顧問として @tenntenn さんに来ていただいた
    • 貴重な知見を惜しまず提供いただいており、感謝しかない状態

結果

  • トレタ民が一段上のGopherになりました。ヤッタゼ

反省点

  • 次世代のアーキテクチャについてはアクションまで行けず
    • 来年の目標に・ω・

まとめ

そんなわけで、つらつら書いてみました。

今年一年で色々と大きく変える形となったため、反発もたくさん来ると思っていたのですが、そんな事は全然なく、皆が前向きに行動してくれました。 まだまだ課題もありながらも開発組織として大きく前に踏み出すことができたのは、皆の協力あってこそだと思います。オマエラサイコウダゼ

来年はこのチームの力でより事業を加速していきたいと思います。
そんなトレタに少しでも興味を持っていただいた方がいれば、ぜひ遊びに来てください。 仲間も募集してます(>∀<)

*1:大変遅れて申し訳ない

*2:誤用かも

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

こんにちは。
サンタ業務を控えているパパ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のこれからに期待しています!

© Toreta, Inc.

Powered by Hatena Blog