クックパッドマートAndroidアプリの画面実装を最高にした話【連載:クックパッドマート開発の裏側 vol.4】

こんにちは。 連載シリーズ4日目を担当します、買物事業部 Androidエンジニアの門田(twitter: @_litmon_ )です。

↓↓↓以前の3日分のエントリはこちらから参照ください↓↓↓

買物事業部では、クックパッドの生鮮食品ECサービス「クックパッドマート」の開発を行っています。 今日は、先日リリースしたばかりのクックパッドマートAndroidアプリを開発する上で、画面実装の工夫について紹介しようと思います。

クックパッドマートAndroidアプリはこちらからダウンロードできます。ぜひ実際に触りながら記事を読んでみてください。 play.google.com

クックパッドマートAndroidアプリの画面実装

クックパッドマートAndroidアプリの主な画面は、大きく分けて3つに分類されます。この分類は、多少の違いはあれど一般的なAndroidアプリに対しても同様のことが言えるのではないでしょうか。

  • 一覧画面: データのリストを一覧表示する画面
  • 詳細画面: 一覧画面の特定のデータに対して詳細を表示する画面
  • 入力画面: データを登録したり追加したりするために入力を行う画面
一覧画面 詳細画面 入力画面
f:id:litmon:20190411123251p:plain f:id:litmon:20190411123345p:plain f:id:litmon:20190411123414p:plain

現代のAndroidアプリ開発において、一覧画面にはRecyclerViewが使われるのが一般的です。RecyclerViewは、同一のレイアウトを複数持つ一覧画面において非常に高いパフォーマンスを発揮するViewですが、AdapterやViewHolderなど実装するものが多く、若干扱いにくいのが難点です。

詳細画面の実装に関しては、スマートフォンのディスプレイは縦に長く、スクロールの方向も縦になるアプリが多いため、 ScrollViewやNestedScrollViewを使ってその中にレイアウトを組むのが一般的だと思います。

入力画面には、EditTextやCheckBoxなどを利用して入力欄を用意すると思います。また、入力項目が多くなった場合には詳細画面同様にScrollViewなどを使ってスクロール出来るように実装することが多いのではないでしょうか。

クックパッドマートAndroidアプリでは、上の例に漏れず一覧画面ではRecyclerViewを使い、詳細画面ではScrollViewを使うというスタイルを取っていたのですが、 このレイアウト手法で開発を進めていくのが大変になっていきました。 特に、詳細画面の実装をScrollViewで行っていくことに関して非常に苦しい思いをした例を紹介します。

レイアウトエディタでのプレビューが活用しづらい

ScrollViewを使って縦に伸びるレイアウトを組む場合、縦に伸びれば伸びるほどレイアウトエディタのプレビュー機能が使いにくくなっていきます。 また、レイアウトファイルも肥大化し、非常に見通しの悪い実装になりがちです。

詳細画面の実装がActivity, Fragmentに集中して肥大化しやすい

RecyclerViewを使うと、Viewの実装の大半はViewHolderクラスに分離することが出来ます。 しかし、詳細画面ではScrollViewを使っているため、データをViewに紐付ける処理をどうしてもActivity, Fragment内に書くことが多くなります。 DataBindingやMVVMアーキテクチャなどを使ってViewの実装をActivity, Fragmentから分離する手法などもありますが、プロジェクトによってはあまり適さないケースもあるでしょう。 また、RecyclerViewを使う一覧画面と実装差異が出てしまい、処理の共通化などが難しくなってしまいます。

なにより実装していて苦しい

詳細画面のような複雑なレイアウト構成を1つのレイアウトファイルに対して上から順に実装していくのは、精神的にも苦しいものがあります。 長くなればなるほどレイアウトエディタ, XML両方の編集作業が難しくなっていくため、細かい単位でレイアウトを分割できるRecyclerViewのような仕組みが欲しくなってきます。

includeタグ?知らない子ですね……

すべての画面をRecyclerView化する

そこで、RecyclerViewをうまく使うことで詳細画面もうまく組み立てることが出来るのでは?と考えました。RecyclerViewは、レイアウトを行ごとに分割して作成することが出来るし、ViewHolderへViewの実装を委譲出来るため、ActivityやFragmentの肥大化を防ぐことが出来ます。 ただ、RecyclerViewの実装には、AdapterとViewHolderの実装が必要で、特に複雑な画面になるほどAdapterの実装が面倒になっていきます。

class DeliveryDetailOrderItemsAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    override fun getItemCount(): Int =
        1 + 1 + items.size + 1

    override fun getItemViewType(position: Int): Int {
        if (position == 0) {
            return R.layout.item_delivery_detail_header_label
        }

        if (position == 1) {
            return R.layout.item_delivery_detail_order_item_note
        }

        if (position == itemCount - 1) {
            return R.layout.item_delivery_detail_order_item_footer
        }

        return R.layout.item_delivery_detail_order_item
    }
}

RecyclerViewで受け取り詳細画面を実装したときの一部を抜粋してきました。 表示するpositionに応じてそれぞれのitemViewTypeを変える必要があるのですが、全く直感的ではなく、頭を使って実装する必要があります。 また、Viewを追加したいという変更があったときに、他の部分にも影響が出る場合があるので、保守性も高くありません。

すべての画面でこのような複雑なRecyclerView.Adapterを実装するのは気が滅入りますし、現実的ではありません。 しかも、読み込んだデータに応じて表示を変えなければいけない、となるとより面倒になるのは必至です。 そのため、RecyclerView.Adapterの実装を簡素に行うためのライブラリを導入することにしました。

Groupieを使う

RecyclerView.Adapterの面倒な実装を便利にしてくれるライブラリは巷にいくつかありますが、今回はGroupieを使うことにしました。 同様の仕組みを持つEpoxyというライブラリも候補に上がっていましたが、判断の決め手となったのは以下の点でした。

  • EpoxyはannotationProcessorを使ったコード生成機構が備わっており、DataBindingと連携させるととても便利だが、クックパッドマートAndroidアプリではDataBindingを使っておらずオーバースペックだった
  • GroupieはRecyclerView.Adapterを置き換えるだけで使えるので非常に簡素で、今回のユースケースにマッチしていた

例えば、Groupieを使って一覧画面のようなデータのリストを表示させるために必要なコードは以下です。

data class Data(val name: String)

class DataItem(val data: Data) : Item<ViewHolder>() {
   override fun getLayoutId(): Int = R.layout.item_data

   override fun bind(viewHolder: ViewHolder, position: Int) {
       viewHolder.root.name.text = name
   }
}

val items = listOf<Data>() // APIから返ってきたリストとする
val adapter = GroupAdapter<ViewHolder>()
recycler_view.adapter = adapter

adapter.update(mutableListOf<Group>().apply {
    items.forEach {
      add(DataItem(it))
    }
})

たったこれだけです。とても簡単ですね。

詳細画面の場合、データの有無で表示する/しないを切り替える必要があったりしますよね。 例えば、クックパッドマートAndroidアプリのカート画面では、カートに商品が追加されていない場合と追加されている場合で表示が異なります。

f:id:litmon:20190411123600p:plain:h300 f:id:litmon:20190411123620p:plain:h300

このようなレイアウトになるようにRecyclerView.Adapterを自前で実装しようとすると、 getItemViewType() メソッドの実装に苦しむ姿が簡単に想像できますね……絶対にやりたくありません。

しかしこれも、Groupieで表現すると以下のように簡単に表現することができます。簡略化のため、表示が変わる部分のみを表現します。

class Cart(
    val products: List<Product>
) {
    class Product
}

class CartEmptyItem : Item<ViewHolder>() { /* 省略 */ }
class CartProductItem(val product: Cart.Product) : Item<ViewHolder>() { /* 省略 */ }

val cart = Cart() // APIから返ってきたカートオブジェクト
val adapter = GroupAdapter<ViewHolder>()
recycler_view.adapter = adapter

adapter.update(mutableListOf<Group>().apply {
    if (cart.products.isEmpty()) {
        add(CartEmptyItem()) // 商品が追加されていない旨を表示する
    } else {
        cart.products.forEach {
            add(CartProductItem(it)) // カートの商品をリストで表示する
        }
    }
})

非常にコンパクトな実装に収まります。 前述の例とあわせて見ると、getItemViewType() を実装するのに比べて直感的になることも理解できると思います。

LiveDataと組み合わせて使う

LiveDataと組み合わせて使う場合もとても簡単です。Fragment内で使うことを例に挙げてみましょう。

class CartFragment : Fragment() {

    class CartViewModel : ViewModel {
        val data = MutableLiveData<Cart>()
    }

    val viewModel by lazy {
        ViewModelProviders.of(this).get<CartViewModel>()
    }

    val adapter = GroupAdapter<ViewHolder>()

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_cart, container, false)
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        recycler_view.adapter = adapter

        viewModel.data.observe(this, Observer {
            it?.let { cart ->
                adapter.update(mutableListOf<Group>().apply {
                    cart.products.forEach { product ->
                        CartProductItem(product)
                    }
                })
            }
        })
    }
}

非常に簡単ですね。 Groupieはupdate時に内部でDiffUtilsを使って差分更新を行ってくれるため、APIリクエストを行った結果をLiveDataで流すだけで簡単に更新が出来ます。

その際、GroupieのItemに対して以下の2点を見ておく必要があります。

  • id が同一になるようになっているか
  • equals が実装されているか

idの設定は、getId() メソッドをoverrideすると良いでしょう。 もしくは、Itemクラスのコンストラクタ引数にidを渡すことも出来ます。

equals() メソッドの実装は、Kotlinならばdata classで簡単に実装することが出来ます。 また、引数を持たないようなItemで、特に中の内容も変わらないような場合は自前で実装してしまっても良いでしょう。

data class CartProductItem(val product: Cart.Product) : Item<ViewHolder>() {

    override fun getLayoutId(): Int = R.layout.item_data

    override fun getId(): Int = product.id

    override fun bind(viewHolder: ViewHolder, position: Int) {
        viewHolder.root.name.text = name
    }
}

// idをコンストラクタで指定
class CartEmptyItem : Item<ViewHolder>(0) {

    override fun getLayoutId(): Int = R.layout.item_data

    override fun hashCode(): Int = 0

    // 同じItemなら同じと判定して良い
    override fun equals(other: Object): Boolean =
        (other instanceOf CartEmptyItem)

    override fun bind(viewHolder: ViewHolder, position: Int) {
        // ignore
    }
}

これらの設定がうまくいっていない場合、更新されたときに無駄なアニメーションが走ってしまうため、できるだけ全てのItemに実装しておくことをオススメします。

実際にクックパッドマートAndroidアプリでは、ほぼすべての画面がこの構成を取って実装していて、画面回転時やFragmentのView再生成にも問題なく状態を再現してくれるのでとても便利な構成になっています。

Groupieを使うことで良くなった点

Groupieを使うことで、RecyclerViewの面倒な実装を簡略化でき、かつすべての画面の実装を定型化することが出来ました。 これにより、以下のような効果が生まれました。

  • Fragmentの実装をすべての画面でほぼ定形化出来るため、精神的に楽に実装できるようになり、かつ処理の共通化が簡単になった
  • RecyclerView.Adapterに比べて、複雑なレイアウトを組むのが非常に簡単なので、実装工数を大幅に削減することが出来た
  • レイアウトが強制的にItem単位になるため、シンプルにレイアウトを作成することが出来るようになった

1つ目の処理の共通化には、例えばエラー画面が挙げられます。 読み込みエラー時の画面表示をGroupieのItemで用意することで、非常に簡単に全ての画面で同一のエラー画面を用意することが出来ます。

class ErrorItem(val throwable: Throwable): Item<ViewHolder>() {
    /* 省略 */
}

apiRequest()
    .onSuccess { data ->
        adapter.update(mutableListOf<Group>().apply {
            add(DataItem(data))
        })
    }
    .onError { throwable ->
        adapter.update(mutableListOf<Group>().apply {
            add(ErrorItem(throwable))
        })
    }

また、アプリ内のItemの間に表示されている罫線も、RecyclerViewのItemDecorationを使うことでアプリ全体で簡単に共通化することが出来ました。 RecyclerViewにLinearLayoutManagerとあわせてdividerを設定することがとても多かったため、RecyclerViewに以下の拡張関数を実装して使うようにしています。

fun RecyclerView.applyLinearLayoutManager(orientation: Int = RecyclerView.VERTICAL, withDivider: Boolean = true) {
    layoutManager = LinearLayoutManager(context).apply { this.orientation = orientation }
    if (withDivider) {
        addItemDecoration(DividerItemDecoration(context, orientation).apply {
            ContextCompat.getDrawable(context, R.drawable.border)?.let(this::setDrawable)
        })
    }
}

Groupieで難しかった点

Groupieを使っていて、難しかった点もいくつかあります。 例えば、受け取り場所の詳細画面には地図を表示しているのですが、MapViewにはMapFragmentをアタッチする必要があります。 単純にaddするだけの実装だと、スクロールして戻ってきたときにクラッシュしてしまうので、unbind時にFragmentを取り除く必要がありました。 苦肉の策ですが、現状は以下のような実装になっています。

data class SelectAreaDetailMapItem(
    val item: Location,
    val mapFragment: SupportMapFragment,
    val fragmentManager: FragmentManager
) : Item<ViewHolder>() {
    override fun getLayout(): Int = R.layout.item_select_area_detail_header

    override fun getId(): Long = layout.toLong()

    override fun bind(viewHolder: ViewHolder, position: Int) {
        val markerPosition = LatLng(item.latitude, item.longitude)
        fragmentManager.beginTransaction()
            .add(R.id.item_select_area_detail_map, mapFragment)
            .commit()
        mapFragment.getMapAsync { map ->
            map.addMarker(MarkerOptions().position(markerPosition))
            map.moveCamera(CameraUpdateFactory.newLatLng(markerPosition))
            map.setMinZoomPreference(15f)
        }
    }

    override fun unbind(holder: ViewHolder) {
        super.unbind(holder)
        fragmentManager.beginTransaction()
            .remove(mapFragment)
            .commit()
    }
}

すべての画面でGroupieを使うことで実装が簡単になった、アプリ全体で処理を共通化出来たというメリットはありましたが、こういう風に扱いに困るケースもあるため、用法用量を守って正しくお使いください。

まとめ

  • Androidアプリ開発において主要な画面はだいたいRecyclerViewで表現できる
  • Groupieを使うと実装も簡単になって最高
  • 難しい画面もあるので適材適所で使い分けよう

おしらせ

4/24(木)に、買物事業部のエンジニアによる発表とエンジニアとのミートアップを開催します!!! cookpad.connpass.com

そこでは、今回語らなかったAndroidアプリの技術的な部分を紹介していこうと思います。 実際のソースコードも見せたり……あるかもしれませんね。 ぜひぜひ!!ご興味のある方は参加お待ちしています!

1枚のラベルの向こうには、1人のユーザがいる【連載:クックパッドマート開発の裏側 vol.3】

こんにちは。クックパッドマート連載3日目を担当します、買物事業部エンジニアの今井(@imashin_)です。

去年の10月ごろから、生鮮食品ECサービスクックパッドマートの販売者向けサービスの開発を行っています。クックパッドマートを利用するのは、商品を買うユーザだけではありません。商品を販売する方々にも簡単に利用できるよう開発を進めています。

今回は、どのようにして商品を販売者からユーザまで届けられるように開発しているかを紹介します。

クックパッドマートではどうやって商品をユーザに届けているのか

まず、今どのように商品を届けているのか、商品の注文から受け取りまでの流れを紹介します。

発注

f:id:ima_shin:20190410170609p:plain

販売者は、四六時中クックパッドマートだけを利用しているわけではありません。これまで通りの生産、販売業務が忙しい中で、クックパッドマートも利用していただいています。

そのため、販売者に合わせた方法を開発し、負担にならないようにしています。

みなさんの近くにある精肉店、青果店を思い出してもらうとイメージが湧くかもしれないのですが、販売者はFAXや電話で注文を受けていることが多いです。必ずしもIT、インターネットに慣れているわけではありません。そのためクックパッドマートは、毎日FAXでの発注書の自動送信を行っています(FAX送信にはTwilioを利用しています)。一方でスマホから見たいという要望の販売者向けに、LINE WORKS経由でも発注書をPDFにて送付しています。利用者の多いLINEと同じUIを提供しているLINE WORKSを利用することで、利用障壁を大きく下げることができています。

仕分

販売者は発注で受けた商品の発送準備を行います。この準備段階で、ユーザが受け取り時に目印とするラベルの貼り付けを行います。

  • 07:00 商品に貼るラベルを遠隔で自動印刷する
  • 07:00-14:00 販売者が注文を受けた商品にラベルを貼る。 商品をコンテナごとに仕分けする

f:id:ima_shin:20190410170612p:plain

商品ラベルについても、完全に操作不要で発行できる構成で設置し、必要な時に必要なラベルを発行しています。また、商品へのラベル貼り間違いが発生せず仕分けが素早くできるよう、コンテナ別、商品別でラベルが発行されるようにソートしています。こうすることで、負担をかけないように工夫しています。

配送

  • 11:00-14:00 配送員がコンテナを受け取りにくる
  • 14:00-17:00 受け取り場所にコンテナを配送する

f:id:ima_shin:20190410170536p:plain

配送員は指定のコンテナを受け取り、冷蔵状態を保ちながら商品を集荷し、受け取り場所まで配送します。

受け取り

  • 17:00- ユーザが受け取り場所にて、自分の注文した商品をピックアップする

ユーザには配送が完了すると通知が送られます。受け取り場所に行き自分が注文した商品のIDを確認し、コンテナからピックアップしていきます。

どのようにして今の配送を作ったのか

クックパッドマートはまだまだ完成していないサービスです。今の配送フローがベストだとは考えていません。これからも日々、改良を続けていきます。

ですが、リリース当初の状態からはかなり改良されています。今回はどのように改良、開発を行っているかを商品の受け取りに必要なラベルの発行にフォーカスして紹介していきたいと思います。

クックパッドマートでは、基本的に

  • 初めから実装せず、頑張る運用からやってみる
  • 頑張る運用の知見を元に、プロトタイプを試験運用する
  • 利用者に当てたプロトタイプの知見を元に、スケール可能なプロダクトを作る

の段階を踏んでサービス開発を行っています。(ex サービスリリース初期の話

今回は商品ラベルの発行にフォーカスして、実際に行った開発をお伝えしたいと思います。

頑張る運用をやる

初期の段階ではコストをかけてでも(後々自動化可能な)配送を行えるのか検証を行いました。商品ラベルの発行は人の頑張りで次のような運用をしていました。

  • 注文の締めとともに社内に設置されている複合機でラベルを印刷する
  • 配送業者にラベルを配送してもらう

f:id:ima_shin:20190410170808p:plain
複合機でのラベル印刷

検証結果としては、商品へのラベル貼りを販売者が問題なく行えることを確認できました。加えて、ラベルに印字したIDを元にユーザが自分の受け取るべき商品をピックアップできることも確認できました。

プロトタイプを販売者にあてる

ラベルを毎日郵送するにはコストが莫大にかさみますし、スケールさせることも困難になります。そこで次の段階として、販売者にラベルを印刷してもらう方向でプロトタイプを作成しました。

安価に、素早く開発できることを基準に技術選定を行い、iPadとiOS用のSDKを提供しているラベルプリンターを採用しました。

  • ラベル発行用iPadアプリを開発し、ラベルプリンターにて印刷できるようにする
  • プロトタイプを店舗に設置し、試験運用してみる
    • ただし、問題発生時にはバイク便にてすぐラベルを届けられるようにバックアップを用意

f:id:ima_shin:20190410171717j:plain

f:id:ima_shin:20190410171833p:plain
ラベルの印刷フロー

このラベル発行アプリとiPadとラベルプリンターを販売者に提供することで、ラベルの配達をなくすことに成功しました。しかしながら、多くの問題点も浮き彫りとなりました。

  • ラベルプリンターの紙詰まりによる故障
    • 耐久性に特化したラベル発行機でないと長期の運用は保守が大変だった
  • 操作可能な画面は不要
    • 導入当初は、発注内容の確認や商品の情報入力をiPadからできるのではと思われたが、実際には設置場所の狭さや操作する余裕がないことがわかった
  • 通信環境の不良
    • iPadが安定してIPアドレスを払い出せない
    • iPadとプリンターの接続状態を安定させることが難しかった
  • OS、アプリの管理
    • 販売者の操作なしにOS、アプリを常に最新状態に保たせる仕組み、運用を作ることが難しかった
  • 販売者ごとのITリテラシーの差異
    • 必ずしも全ての販売者がiPadやプリンターの操作に慣れているわけではなかった

このように実際にプロトタイプで試験運用した結果、多くの問題点を洗い出すことができました。ラベルは商品を販売者からユーザに届けるために必要不可欠なものです。毎日必ず発行できる安定性を実現させる必要があります。

スケールできるプロダクトを作る

安定してサービスを運営するためには、ラベル発行に高い安定性が必要だということを認識することができました。また広くスケールをさせるためには、誰でも簡単に設置、管理できる必要があります。そこで、以下のような要件を元にスケール可能なプロダクトの開発を行いました。

  • 安定してラベルを発行できる構成と設計
    • 完全に遠隔でラベル発行をコントロールできる
    • ラベル発行が可能か把握するための死活を監視する
    • ラベル切れ等によるラベル発行不可能状態になる事前に検知する
  • 簡単に導入、運用できる設計
    • 電源を刺すだけ利用できる
    • 複雑な操作なしに運用できる

以上を満たすように開発を行い、つい2週間ほど前に新たな構成でプロダクトをリリースしました。

今の状態

では、今の構成がどのようなものかを紹介しようと思います。

ハードウェア

安定した稼働を実現するために、以下の機器でラベル発行機を組みました。

  • LTE ルーター UD-LT1/EX + SORACOM Air
    • 定期リブート
    • ネットワーク断絶時のリブート
    • Syslog
    • 外部ネットワークからの設定変更
    • SNMPによる状態監視
  • TSP743IIE3-24J1 JP
    • 通電すると常にONの状態に固定可能
    • 紙詰まりしにくい
    • ネットワーク経由でコントロール可能
    • カバーが開いている、紙が詰まっている、ラベルが切れかかっている等の状態を取得可能
    • SNMPによる状態監視
  • Raspberry Pi Model B+
    • デバッグ、キッティング、監視用

f:id:ima_shin:20190410171946p:plain
ラベル発行の構成

各機器の安定性、死活監視を利用することで、ラベル発行を安定して行うことができるようになりました。Raspberry PiでLTE通信を行うこともアイデアとしては挙がっていましたが、リリース速度を重視し、一旦既存のルータ製品を採用することにしました。

ソフトウェア

以上のハードウェアを稼働させるために、主に3つの開発を行いました。

star_ethernet

スター精密製プリンターを制御するiOSやAndroidのSDKは提供されていたのですが、サーバから直接利用するケースが少ないのか、Rubyはサポートされていませんでした。しかし、ソケット通信によるプリンターのプロトコルについて、細かな仕様が提供されていたため、Rubyからスター精密製プリンターを制御するgemを作成しました。

基本的にはTCPソケットで制御コマンドを送信し、プリンターを制御します。公開されている全てのコマンドをラップし、ラベル発行に必要なハンドリングを可能にしています。

例えば、文字を大きくしたりレイアウトを変えたりする、QRコードを印字する、線を引くといった印字内容の操作もこれを用いて行います。ラベル台紙のカットやラベル送り、ビープ音を出すこともできます。プリンターの細かな状態を取得することもできます。

f:id:ima_shin:20190410172040p:plain
https://www.starmicronics.com/Support/Mannualfolder/UsersManual_IFBD_HE0708BE07_EN.pdf

mart_server

プリンターへのラベル発行命令はECSから送信します。

mart_server(クックパッドマート全体を支えるRailsアプリケーション)に発行すべきラベルの情報を集約し、日次バッチにて発行するラベル情報をstar_ethernetを利用してプリンターに送信します。

バッチにはkuroko2を利用し、barbequeで各プリンターへのラベル発行ジョブの管理しています。何かしらのトラブルでラベル発行に失敗した時は、原因を調査しジョブを再実行することで全てのプリンターで確実にラベルが発行されるようにしています。またラベル残量が少なくなっていたり、紙詰まりの発生を検知しています。

mart_shepherd

配布端末、ネットワークの管理を新たなのアプリケーションとしてmart_shepherdに切り出しました。

mart_shepherdはSORACOMプラットフォームとの間に立ち、mart_serverからgRPC経由のリクエストに応じて端末の管理を行います。また、ルーター、プリンター、ラズパイ各端末との通信時にはプロクシを行い、通信路を確立します。

アセンブル

実際にこれらの構成を設置するためには、機器を一つの什器にまとめてコンパクトにする必要があります。また、電源を刺すだけで簡単に運用を開始できるようにすることを目指しました。

そこで、一つのボックスに全ての機器を収納し、プラグを刺すだけで全ての機器の電源がONになり、即座に運用状態になるようにしました。

弊社には、ハードウェアを加工できる「工房」と呼ばれるスペースが存在し、そこで全ての加工、組立を行いました。

f:id:ima_shin:20190410172304j:plain
加工中の様子

f:id:ima_shin:20190410172230j:plain
加工済みのボックス

f:id:ima_shin:20190410172532p:plain
アセンブル済のボックスとプリンター

改善点

以上のように、安定して稼働するプロダクトを完成させることができました。2週間運用している限りでは、何かトラブルが発生しても遠隔で復旧することに成功していて、ラベルの発行ができない問題にぶつかったことはありません。

しかし、まだ改善点は残っています。

  • 低コスト化、小型化
  • 輸送可能な構成、耐衝撃性
  • 熱制御

これらを実現するために、引き続き開発を行っています。

まとめ

たかが商品のラベル1枚と思いがちですが、ラベルが発行されないとユーザーに正確に商品を届けることができません。1枚のラベル発行に失敗すると、1人のユーザが料理を作れない状態に陥ってしまいます。

そのようなことが決して起きないよう、たかがラベルの発行であっても真剣に開発に取り組んでいます。

これからもクックパッドマートは、素早いサイクルでの開発の元、安定したサービスの提供と、スケールを実現していきます。

この記事を通して、クックパッドマートのサービス開発にご興味を持っていただけた方がいらっしゃいましたら、ぜひ一緒にサービスを作りましょう!

www.wantedly.com

お知らせ

クックパッドマートでは、4/24(木)に、買物事業部のエンジニアによる発表とエンジニアとのミートアップを開催予定です。

cookpad.connpass.com

今回の記事のような生鮮ECそのものの仕組みや、流通の仕組みの開発に興味がある方、クックパッドマートのエンジニアと直接話してみたい方はぜひご応募ください!お待ちしています!

クックパッドマートiOSアプリを楽しく新規開発した話【連載:クックパッドマート開発の裏側 vol.2】

こんにちは。
連載シリーズ2日目を担当します、クックパッド買物事業部 iOSアプリエンジニアの中山(@LimiterJP)です。
早いもので入社して一年が経ちました。

私は去年4月にクックパッドへ入社しました。
その後6月にアプリ開発を始め2018年9月に「クックパッドマート」のiOSアプリをリリースしました🎉

クックパッドマートは
生鮮食品をスマホアプリから簡単に注文することができる生鮮食品ECサービスです。
従来のネットスーパーや生鮮宅配サービスとは異なり、街の精肉店や鮮魚店などの販売店や地域の農家といった生産者など、小規模事業者や個人事業主が参加できるプラットフォームです。

今日はクックパッドマートのiOSアプリの立ち上げを爆速で行った舞台裏について、

  • 新規開発で大切にしたこと
  • 開発速度を上げるための考え方
  • 新規開発に特化した具体的な手法

の観点でお話します。

新規開発で大切にしたこと

ここでは、私が普段から新規開発を行う際に心がけていたことをいくつか紹介します。

開発スピードは大切

開発スピードは早ければ早いほど失敗から機能改善・成功までの速度も上がると考えます。

「ユーザーが実際にアプリを使用し、内容に共鳴・共感しリテンションを保てるか?」
という仮説検証を行う際、
「実際にアプリをリリースし、ユーザーが使用して操作してみないとわからない」
ということを認識する必要があります。

ユーザーがアプリをダウンロードしたときの印象にはレベルが存在すると考えます。
レベル1 使えない・よくわからないアプリ
レベル2 何に使うのか?を理解できる内容のアプリ
レベル3 なかなか使えるので使いつづけようと感じるアプリ
レベル4 神アプリ!シェアしよう!拡めたいと思うほどのお気に入りのアプリ

f:id:degikids:20190408134227p:plain

そのうち最低でもレベル3以上を目指さないと結果を出すことは難しいでしょう。
そして瞬間的にレベル4でも使い続けてもらえない内容だと別の問題にも悩むことになります。
アプリ開発って本当に難しい。

アプリリリース前にしっかり使用するシーン・ストーリー・コンセプト・カスタマージャーマップを組み立て入念な企画を立案します。
デザインを当ててみて機能を設計するなど頭の中でアプリを開発する。
ここまでの工程を私達のチームでは「論理できた」と呼んでいます。

ところがこの「論理できた」の状態でいざ実際にリリースしてみると
「実際のユーザーが継続的に使用することはなくニーズがずれて失敗する」
ことが多いのではないでしょうか?

これらは機能単位で起こっているなんてこともあります。
時間をかけて作った機能が使われないなんてことよくありますよね。

そのため、速く改善してレベル3, 4を目指せる状態を作ることでリテンション(継続率)を高められる状態になると考えます。

スピード重視とはいえ、雑に実装してバグを出してでもスピードを求めるとか
「コードレビューを全く行わないぞ、テストコードを書かないぞ!」とかそういうことではありません。
行う箇所を選定・判断することが大切です。
大切なのは効率化して工夫できることの選択肢を増やし開発スピードを向上させることです。
(後述する「開発速度を上げるための考え方」など)

チームメンバーとよく笑いよく話す

クックパッドマートのチームでは、基本的に誰かが面白いことを言っている現場なので笑いが絶えません。 (誰か反応するまで喋り続けるスタイルです!)

デザイナー、エンジニア、ディレクターなど職域を超えた人同士の雑談も多く、 Slackでやり取りすれば良いようなこともあえて喋るように、人間関係も良好で良いチームだと感じています。
そのおかげか、メンバーがどういう気持ちで仕事に向き合っているか、何を考えているのかも知ることができています。 業務で疑問に思ったことは身近な人に聞けば一瞬で解決することも多く、様々な仕事が円滑に進むので、会社に来ることに楽しさも感じます。

「普段から高い頻度で雑談ができるチームは強い。」 その結果、開発速度も改善速度も上がり、品質も高くなると考えています。   

圧倒的当事者意識

「圧倒的」とつけるとなんだかすごい感じがするのでつけてみたのですが当事者意識は非常に大切です。
自分が作っているサービスを使い倒す。使わないと問題点や改善点は理解できないですよね。
クックパッドマートで販売されているパンはとても美味しいのです🍞
エンジニア自身がアプリの課題を把握することで、自分が使いやすくするためには?という意識が自然に芽生えるものです。
よってアプリをしっかり普段使いすることは非常に重要です。

開発速度を上げるための考え方

ここでは、具体的にどのように開発速度を上げていくか、私なりの考え方について紹介します。

パレートの法則(2:8の法則)で物事を考える

パレートの法則とは全体の数値の大部分は全体を構成するうちの一部の要素が生み出しているという理論です。

例えば「アプリ利用者のうち8割は、全機能の2割しか使わない」とすると、

  • すべての機能のうち2割の重要な機能に集中する
  • 2割のユーザーしか使わない機能はほどほどに作る

などのヒントが得られそうです。

何が正しいかはチームで決めると良いでしょうし、実際にそのまま採用するのではなく頭で考える作業をします。 明らかに仕様頻度が少なく重要度の低いものに時間をかけない選択やphase分けをして最初のリリース時には実装しないなどの判断をすればいいと思います。

パレートの法則に当てはめることで、

  • 開発の優先順位をつけることでリリースまでの最短距離の見通しが良くなる
  • なにか取り掛かる際にこれらを意識することで開発スピードが目に見えて上がる

などの効果があると考えています。 やる事とやらない事をはっきりさせ、見通しをよくする事が大切ですね。

何事もphaseで分けた考え方を持つ

なぜならはじめから大きなものを作ろうとすると疲弊し、その他がおろそかになる可能性があります。
「段階を経て完成を目指す」というのでしょうか。
この時点ではその「作ろうとしているもの」がユーザーに受け入れられるかはわかりません。

そこで比較的工数がかかる実装はphaseで分けて考える場面があると思います。 具体的には複数の緯度経度と場所情報の情報を持つリスト構造の情報があったとします。

リスト構造の情報を地図上にピンが立てタップし直感的に俯瞰できると見やすいでしょう。 しかしリリース序盤だと受け取り場所の情報が少なすぎて 逆に見づらい上に寂しい感じもします。

f:id:degikids:20190408134619p:plain

そこでリリース時はただのリスト構造からタップして選択するUIを選択し、ロケーションが増えたらMapからピンをタップして選択するUIへ再構築するという判断が生まれます。

新規開発に特化した具体的な手法

ここでは、iOSアプリで新規開発を行う際の具体的な手法について紹介します。

同じコードを極力書かないようにコードスニペットは磨いておく

高い頻度で使用するコード記述をスニペットに登録しておけばいちいちGoogle検索したり、昔書いたソースコードを探してみたりすることなく、 使いたい時に正確な記述をサッと呼び出して使うことが可能です。

存在は知っていてもあまり使用されていないのが現実です。 私はどの言語を書くときでもIDEに付属しているコードスニペットを活用しています。

f:id:degikids:20190408142542p:plain

コードスニペットは定期的に磨いておくと開発効率が抜群に上がります。
iOSではXcodeのsnippetを活用しおり開発をしているとこれらは何度も登場します。

  • TableView, CollectionViewの最低限動くdelegateメソッド一式
  • GoogleMapの最低限動くdelegateメソッド一式
  • 地図からルート探索
  • 緯度経度のリスト構造と現在の位置情報からdistanceが近い順に並べ替え
  • UIActivityやShareなどのイベントハンドラ
  • 位置情報取得(パーミッションdelegate含む)
  • アラートやフルスクリーンの透過ダイアログ・モーダルウィンドウ各種
  • WebView・SafariViewの設置
  • チュートリアルなどのスライドをcollectionViewのpagingを使用して実装
  • カメラ起動 最低限動くdelegateメソッド一式
  • ライブラリから写真選択、アルバムから画像抽出
  • プッシュ通知の実装
  • GCD各種

登録時はCompletion Scopes でしっかり分類しcompletion shortcut は検索性を保った名称設計を心がけます。 たったこれだけでも、手を抜いてしまうと開発効率は落ちます。

人にもよりますが私の場合すべてのsnippet の completion shortcut prefixにsw_をつけています。(sw_はswiftを表していますObjective-C時代からの歴史的経緯もあります)

sw_ から始まるものはすべてsnippetであるという分類ルールを持ち通常の補完と区別しやすいようにします。

f:id:degikids:20190408142347p:plain

普段SwiftでXcodeを使用してコーディングする時は

sw_xxxx で補完

または

 command + shift + 「l」

検索

↑↓cursor

Enter

の順でコードを書いていきます。

その結果、通常のコード補完よりも早く正確に書けるようになります。 初見のコードも一旦雑に書き、使い回せるようにコードレビューを繰り返し、磨き上がったらsnippetに丁寧に放り込みます。

チーム開発するときは
~/Library/Developer/Xcode/UserData/CodeSnippets を共有しておくととても便利です。

例えばアプリ上でGoogleMapを設置しピンを立てて位置情報取得して画面でみて見ましょうか?という場面があったとします。 プロトタイピングツールでは難しい地図のモック作成の場面でも短時間で実装しディレクターやデザイナーと実際に地図を動かし良い悪いの議論することができます。この時点でプロトタイピングツールすら必要なく開発を進めることができます。

Xcodeのプロジェクトテンプレートを磨いておくと結果的にすべての開発効率が向上する

私はxcodeのプロジェクトテンプレートを利用しています。(具体的な用意の仕方はここでは割愛します)

ですが単純に自分の雛形プロジェクトテンプレートを用意しておけば良いと思っています。(新規プロジェクトで雛形アプリを作成)

以下のような機能を雛形の中に含めています。

  • 設定画面
  • TabBarController
  • お問い合わせ(WebView or SafariView)
  • アプリの使い方(WebView or SafariView)
  • プッシュ通知
  • 利用規約(同意の機構)
  • プライバシーポリシー
  • アプリバージョンの表記
  • ライセンスの表記
  • レビュー催促の仕組み
  • ログイン画面
  • Cloud FirestoreのCRUD

その他たくさん

まず上記を含んだ雛形のプロジェクトを複製し、その案件で必要ない機能は削除していきます。
上記の実装がXcodeから新規作成したときに既に実装されていたらどうでしょうか?
それはもう「強くてニューゲーム」です。

f:id:degikids:20190408140326p:plain

最初のスタートダッシュで大きな差がつきます。

Storyboardベースでつくる

Xcode6以前はコードベースで進めるほうが効率的でしたが、
iOS9.0以降からはStoryboardの分割が容易になりコードベースで作るより圧倒的に開発が楽になりました。

Storyboard Reference の登場によりファイルの分割や関連付けも容易に。
以前と比べてConflictの可能性も低くくなったと感じます。

f:id:degikids:20190408140945p:plain

Storyboardを活用しデザイン確認が行えると開発が円滑に進む場面が多いように思えます。
例えばちょっとしたボタンの位置、配色などデザイナーと机を合わせて確認・議論ができます。簡単なモックもコードを汚さず作ることが可能です。

SwiftGenを活用する

SwiftGenとはXcodeで使用される画像イメージ、フォント、 カラー、segue等のリソース名を自動的に生成し型付を行ってくれるライブラリです。 https://github.com/SwiftGen/SwiftGen

Storyboardの遷移はコードで行っています。
理由として再利用率が高い画面を切り離して考えられるからです。

簡易的な値渡し例えばWebViewで開く情報を segue で渡すとシンプルかつ直感的に実現できます。

画面遷移では performSegue を使用しており、prepere で値渡しを行っています。
そのためにはSegueIDをStoryboardから設定する必要がありこれを手作業で管理するのは厳しい。
Segueのデメリットは「いろんな場所に設定が散らばっていて情報が隠れやすく流用がしづらい」ということだと思います。
コードからSegueIDやStoryboardを利用しようとした場合に、補完が効かないためハードコードをするか、手動で管理をする必要があります。

そこでSwiftGenの登場です。
簡単にStoryboardの名前からStoryboardのSegueのIDで名まで自動で生成し型付してくれます。

f:id:degikids:20190408142658p:plain

buildしなくても角丸とかボーダーなどを確認できるようになります。

class CustomView: UIView {
    @IBInspectable var customBool: Bool = false
    @IBInspectable var customInt: Int  = 0
    @IBInspectable var customFloat: CGFloat = 0.0
    @IBInspectable var customDouble: Double = 0.0
    @IBInspectable var customString: String = ""
    @IBInspectable var customPoint: CGPoint = CGPointZero
    @IBInspectable var customSize: CGSize = CGSizeZero
    @IBInspectable var customRect: CGRect = CGRectZero
    @IBInspectable var customColor: UIColor = UIColor.clearColor()
    @IBInspectable var customImage: UIImage = UIImage()
}

まとめ

クックパッドマートのiOSアプリはまだまだ発展途上で完成はしていません。
現在もコツコツと開発を進めており日を重ねるごとに利便性は向上しています。
使用できるエリアの皆さんは是非使っていただけると幸いです。ありがとうございます!
この記事を通して、クックパッドマートのサービス開発にご興味を持っていただけた方がいらっしゃいましたら、ぜひ一緒にサービスを作りましょう!

www.wantedly.com

お知らせ

クックパッドマートでは、4/24(木)に、買物事業部のエンジニアによる発表とエンジニアとのミートアップを開催予定です。

cookpad.connpass.com

今回の記事のような生鮮ECそのものの仕組みや、流通の仕組みの開発に興味がある方、クックパッドマートのエンジニアと直接話してみたい方はぜひご応募ください!お待ちしています!