SwiftUIでStickyなタブを実現する

こんにちは。クックパッド事業部でiOSアプリの開発をしている新堀(@tk108gabalian)です。
iOS版のクックパッドアプリではスクロール時にコンポーネントが上部に固着する画面があります。 所謂StickyHeaderというやつです。 今回はそのStickyHeaderをSwiftUIで、かつTabViewを使用つつ実現する方法について紹介します。

導入の背景

2022年7月にリリースした「のせる」画面には以下の要件がありました。

  • 画面上部にユーザー情報を表示する。
  • ユーザー情報の下にタブを表示する。
  • タブをタップするか、タブより下を横にスワイプすることでタブの切り替えが可能。
  • 画面全体をスクロールできるが、ユーザー情報が隠れるまでスクロールしたら画面上部にタブが固着し、以降はタブより下の部分のみスクロールする。(逆方向にスクロールする場合は再度ユーザー情報が表出する。)

また、事業的な希望ではありませんが、開発メンバー達の中ではSwiftUIで画面を実装したいという気持ちがありました。 というのも、この「のせる」以前の画面は基本的にUIKitで作られており、SwiftUIと比べてUIの変更に時間がかかることが課題としてあったからです。

UIKitでこのStickyHeader実現することは可能だと分かっていました。
なぜなら、同クックパッドアプリの「きろく」画面にて、UIKit製のStickyHeaderが既に実装されていたからです。
そのため、UIKitを使えば確実に要件を満たすものが作れると分かりつつも、SwiftUIで同様の振る舞いを実現する方法を探求することにしました。

TabViewなしのStickyHeaderの実現方法

横スクロールによるタブの切り替えを実現するため、SwiftUIのTabViewを使うことを検討していました。 しかし、検証を進めると、TabViewを使いつつStickyHeaderを実現する難しさが分かってきました。

まずはTabViewを使わない場合のStickyHeaderの実現方法を見てみます。
これはScrollView内のコンテンツでLazyVStackなどを使用し、pinnedViews.sectionHeadersを指定した上で、LazyVStackの中のSectionにheaderとなるViewを指定するだけです。

struct ContentView: View {
    var body: some View {
        ScrollView {
            LazyVStack(pinnedViews: .sectionHeaders) {
                Section(header: Text("ここが固着する")) {
                    ForEach(1...50, id: \.self) { number in
                        Text("Row \(number)")
                    }
                }
            }
        }
    }
}

しかし、上記の方針だとScrollViewの中にTabViewを入れる時に問題が発生します。
ScrollViewにジェスチャーが吸われるので、TabViewの中身を横スワイプで切り替えることができません。 (一応TabViewのselectionを切り替えるボタンを用意すればタブを切り替えることはできます。)

また、ScrollView内にTabViewを配置する場合、TabViewの高さがframe modifierのheightによって明示的に指定されないと、高さが確定せずに何も表示されません。 よって、TabView内のコンテンツの高さを全て計算してheightを設定する必要があり、かつそれがタブごとに、またタブの切り替えごとに必要になります。

このように、TabViewとpinnedViewsを併用してStickyHeaderを実現しようと思うと、TabViewの旨みである横スワイプが無効になり、さらにTab内のすべてのViewの高さ計算が必要になってしまいます。

ここまでの調査でTabViewを使用しつつStickyHeaderを実現することが簡単ではないことが分かりましたが、検証を進めていく中で要件を満たす実装に辿り着くことができました。

動作環境

iOS Deployment Targetが 14.0 以上を想定しています。 ※ iOS13での動作は未検証となっています。

画面構成

まず初めに、StickeyHeaderを実現している画面の構成について説明します。

前提として、iOS版クックパッドアプリでSwiftUIを採用している画面では、SwiftUIのViewをUIHostingControllerでラップし、そのUIHostingControllerのviewを親となるUIViewControllerに載せて使っています。
上記の構成はSwiftUI を活用した「レシピ」×「買い物」の新機能開発で紹介されているので、詳しくはこちらをご参照ください。

SwiftUIのViewは以下のような階層になっています。

struct FooTabView: View {
    @Binding var selection: TabType

    private var topAreaHeight: CGFloat {
        Header.height + TabBar.height
    }

    var body: some View {
        ZStack(alignment: .top) {
            tabView

            VStack(alignment: .center, spacing: 0) {
                Header()
                TabBar(tabTypes: TabType.allCases, selection: $selection)
            }
        }
    }

    @ViewBuilder
    private var tabView: some View {
        TabView(selection: $selection) {
            ForEach(TabType.allCases) { _ in
                ScrollView {
                    Content()
                }
            }
        }
        .padding(.top, topAreaHeight)
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
    }
}

ZStackを配置し、ZStack内にTabViewとヘッダー部分を配置します。 この時、ヘッダーが前面、TabViewが背面になります。 背面のTabViewにはTabごとに縦方向のScrollViewを配置します。 そのままではTabViewが上部のヘッダーに隠れてしまうので、TabViewのtopにpaddingを付けます。

TabViewありのStickyHeaderの実現方法

次にスクロールに合わせて前面のコンポーネントが動くように修正し、さらに一定以上は動かなくなる(画面上部で固着する)振る舞いを実現する方法を紹介します。
以下の2つのステップに分けて説明します。

  1. SwiftUIのScrollViewからスクロール量を取得する。
  2. 取得したスクロール量を使って上部のコンポーネントを動かす。

1. SwiftUIのScrollViewからスクロール量を取得する

スクロール量を取得するため、SwiftUIのGeometryReaderとPreferenceKeyを使います。
GeometryReaderは親となるViewのサイズや座標を取得するAPIです。 ScrollView内にGeometryReaderを配置することで、ScrollView内のコンテンツのY座標を取得できます。(このY座標が実質スクロール量になります。) GeometryReaderから座標とサイズを取得するには、GeometryReaderのinitializerのclosureからGeometryProxyを受け取る必要があります。 しかしこのclosureは@ViewBuilderとなっており、このスコープ内で@Stateな変数を上書きすることはできません。

そこでPreferenceKeyが登場します。
PreferenceKeyを使用すると子Viewから親Viewに値を受け渡すことができます。 まずGeometryReader内に透明なViewを配置します。これで@ViewBuilderのコンパイルできる条件を満たします。 そして上記の透明なViewでPreferenceKeyを使用し、親ViewにGeometryProxyのy座標を渡します。

ここまでの一連の処理を一つのViewで行えるようにし、利用元はclosureからスクロール量を受け取ることができるようにしました。

private struct ScrollViewOffsetYPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = .zero
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}

public struct OffsetReadableVerticalScrollView<Content: View>: View {
    private struct CoordinateSpaceName: Hashable {}

    private let showsIndicators: Bool
    private let onChangeOffset: (CGFloat) -> Void
    private let content: () -> Content

    public init(
        showsIndicators: Bool = true,
        onChangeOffset: @escaping (CGFloat) -> Void,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self.showsIndicators = showsIndicators
        self.onChangeOffset = onChangeOffset
        self.content = content
    }

    public var body: some View {
        ScrollView(.vertical, showsIndicators: showsIndicators) {
            ZStack(alignment: .top) {
                GeometryReader { geometryProxy in
                    Color.clear.preference(
                        key: ScrollViewOffsetYPreferenceKey.self,
                        value: geometryProxy.frame(in: .named(CoordinateSpaceName())).minY
                    )
                }
                .frame(width: 1, height: 1)
                content()
            }
        }
        .coordinateSpace(name: CoordinateSpaceName())
        .onPreferenceChange(ScrollViewOffsetYPreferenceKey.self) { offset in
            onChangeOffset(offset)
        }
    }
}

2. 取得したスクロール量を使って上部のコンポーネントを動かす

スクロール量が取得できたらそれを使ってヘッダー部分を動かします。
SwiftUIではoffset modifierを変更することで描画地点をずらすことができます。
①これを利用し、取得したスクロール量をoffsetに渡すことでヘッダーを動かします。

before after

しかし、スクロール量をそのままoffsetに反映させ続けると、固着して欲しいタブの部分が画面の上に突き抜けていってしまったり、動いて欲しい方向とは逆向きにヘッダーが動いてしまいます。
②そこで、offsetの範囲をあらかじめ決めておき、その範囲内でoffsetを変えるようにしています。

before after

また、ヘッダーが上に動いているのにヘッダーより下のコンポーネントが動いていないと、ヘッダーの下に隙間ができてしまいます。
③これを防ぐために、ヘッダーより下のコンポーネント(参考実装で言うところのtabView)のpaddingにoffsetを反映させ、ヘッダーに追従して動くようにしています。

before after

しかし、上記のpaddingの変更によってヘッダー以下のコンポーネントの位置が変わると、その分ScrollView内のコンテンツのY座標も動いてしまい、スクロールとpadding変更の両方が合わさった結果、倍速のスクロールが発生してしまいます。
④これを防ぐために、ScrollView内のコンテンツのoffset modifierにて反転したoffsetを指定することで、paddingの変更分を相殺し、倍速でスクロールしないようにしています。

before after
struct FooTabView: View {
    @Binding var selection: s
    @State private var offset: CGFloat = .zero

    private var topAreaHeight: CGFloat {
        Header.height + TabBar.height
    }

    var body: some View {
        ZStack(alignment: .top) {
            tabView

            VStack(alignment: .center, spacing: 0) {
                Header()
                TabBar(tabTypes: TabType.allCases, selection: $selection)
            }
            .offset(y: offset) // ①
        }
    }

    @ViewBuilder
    private var tabView: some View {
        TabView(selection: $selection) {
            ForEach(TabType.allCases) { _ in
                OffsetReadableVerticalScrollView(onChangeOffset: updateOffset) {
                    Content()
                        .offset(y: -offset) // ④
                        .padding(.bottom, topAreaHeight)   
                }
            }
        }
        .padding(.top, topAreaHeight + offset) // ③
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
    }

    private func updateOffset(_ newOffset: CGFloat) { // ②
        if newOffset <= -topAreaHeight { // HostingControllerを使わない場合、ここにsafeAreaを高さを足す必要がある。
            offset = -topAreaHeight
        } else if newOffset >= 0.0 {
            offset = 0
        } else {
            offset = newOffset
        }
    }
}

ここまでの実装で、上部コンポーネントの移動とタブの固着が実現できます。

対処した問題

前章で紹介した実装でタブの固着は実現できるのですが、実装に進むと以下の問題点に気づきました。

  1. タブを切り替える時にoffsetが大きくずれてしまう。
  2. iOS15系だとサイズクラスの変更の際にTabView以下の表示が崩れてしまう。
  3. TabView内のコンテンツの高さがスクロール領域の高さと同じくらいの時に、画面がガタガタしてしまう。

タブを切り替える時にoffsetが大きくずれてしまう

スクロール量の取得とoffsetの変更はタブごとに行なっています。
そのため、タブを切り替えるとスクロール量も大きく変更される可能性があります。
その際にヘッダー部分のoffsetが追従していないと、ScrollView内のコンテンツがヘッダー部分に隠れたり、ScrollView内のコンテンツのTopとヘッダーの間に大きな余白が見えてしまいます。

これを防ぐために、タブごとのスクロール量を保持しておき、タブの切り替えの際に変更先のタブのスクロール量を流すようにしています。 (コードは簡略化したものです。)

struct OffsetReadableTabContentScrollView<TabType: Hashable, Content: View>: View {
    let tabType: TabType
    var selection: TabType
    let onChangeOffset: (CGFloat) -> Void
    let content: () -> Content

    @State private var currentOffset: CGFloat = .zero

    public var body: some View {
        OffsetReadableVerticalScrollView(
            onChangeOffset: { offset in
                currentOffset = offset
                if tabType == selection {
                    onChangeOffset(offset)
                }
            },
            content: content
        )
        .onChange(of: selection) { selection in
            if tabType == selection {
                onChangeOffset(currentOffset)
            }
        }
    }
}

iOS15系だと、サイズクラスの変更の際にTabView以下の表示が崩れてしまう

これはSwiftUI側の問題のようなのですが、iOS15系でTabViewを使用している時、タブを2ページ目以降にした状態でサイズクラスを変更すると、タブ内の表示が崩れる不具合がありました。(iOS14、16系では再現しない。)

そのため、iOS15系のみサイズクラスの変更を検知した際のworkaroundを入れることにしました。
「のせる」画面ではUIHostingControllerを使用していたため、UIHostingControllerのviewを保持するUIViewControllerでサイズクラスの変更を検知し、SwiftUIのViewへイベントを流すようにしました。

ただし、この方法には一つ別の問題があります。
それはサイズクラスの変更によってViewが再生成されるため、showログが送り直されたりスクロール位置がリセットされてしまうことです。 しかし、ユーザー数の多いiPhoneでは画面回転を無効にしていてサイズクラスの変更が発生せず、iOS15系のiPadのみで発生する可能性があることから、許容する方針としました。

override public func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    if screenSizeDataSource.screenSize != view.bounds.size {
        screenSizeDataSource.screenSize = view.bounds.size
    }
}
struct FooTabView: View {

    @State private var layoutTrigger = false

    var body: some View {
        ZStack(alignment: .top) {
            if layoutTrigger {
                tabView
            } else {
                tabView
            }
            ... 
        }
        .onChange(of: screenSize) { _ in
            if #unavailable(iOS 16.0), UIDevice.current.userInterfaceIdiom == .pad {
                layoutTrigger.toggle()
            }
        }
    }
    ...
}

TabView内のコンテンツの高さがスクロール領域の高さと同じくらいの時に、画面がガタガタしてしまう

ちょっとgifだと分かりづらいのですが、以下のような挙動になります。

ヘッダーが完全に縮小していないかつスクロールの最下部に達した状態でさらにスクロールしようとすると、スクロールのバウンス*1によってoffsetが小刻みに増減し、Viewが振動しているような振る舞いをします。
この挙動を回避するため、ScrollViewがコンテンツの最下部に達したかどうかを判定し、さらにバウンスが発生する向きにスクロールしようとしている場合はoffsetを更新しないようにしました。

private func updateOffset(_ newOffset: CGFloat, _ didUpdateByTabChange: Bool, _ hasReachedContentBottom: Bool) {
    // タブの中身のスクロールが最下部に達してもヘッダーが完全に縮小していない状態でさらに下にスクロールすると、スクロールのバウンスによって offset が小刻みに増減し画面がガタガタしてしまう
    // タブの中身のコンテンツの最下部が表示されている かつ さらに下にスクロールしようとしている場合は offset を更新しないようにする
    // ただし、タブの選択が切り替わった時( didUpdateByTabChange が true の時)の offset 更新は常に行う
    if !didUpdateByTabChange && hasReachedContentBottom && (newOffset < offset) { return }

    if newOffset <= -maxHeaderOffset {
        offset = -maxHeaderOffset
    } else if newOffset >= 0.0 {
        offset = 0
    } else {
        offset = newOffset
    }
}

ScrollViewのコンテンツが最下部に達したかどうかの判定は透明なViewのonAppear/onDisapperによって行なっています。

private struct ReachedContentBottomTracker: View {
    @Binding var hasReachedContentBottom: Bool

    var body: some View {
        // View が表示された時に onAppear/onDisappear が呼ばれるようにしたいため LazyVStack で囲っている
        LazyVStack {
            Color.clear
                .frame(width: 1, height: 1)
                .onAppear { hasReachedContentBottom = true }
                .onDisappear { hasReachedContentBottom = false }
        }
    }
}

Content()
    .background(ReachedContentBottomTracker(hasReachedContentBottom: $hasReachedContentBottom), alignment: .bottom)

その他検討した実現方法

以下の2つの方法も実現が可能か調査しました。

  1. UIKitのUIScrollViewをUIViewRepresentableで使用する。
  2. Introspectを使用し、ScrollViewの裏側のUIScrollViewのdelegateを使用する。

1. UIKitのUIScrollViewをUIViewRepresentableで使用する

UIViewRepresentableはUIKitのViewをSwiftUIで使用するためのAPIです。
UIKitのUIScrollViewではcontentOffsetを取得することでスクロール量が分かります。 そのため、UIScrollViewをSwiftUIのViewに組み込めば、SwiftUIのScrollViewを使わずにスクロール量を取得することが可能です。
しかし、UIViewRepresentableを使用すると、過去にサイズクラスの変更やdynamicTypeの変更を追従してくれない不具合が発生しており、採用は見送りになりました。

一方で、UIKitのUIScrollViewを使用するメリットもありました。 それは精度の高いスクロール量を取得できることです。

後述しますが、今回紹介した方法でScrollViewから取得できるスクロール量は、UIScrollViewから取得できるスクロール量より粗いです。 取得できるスクロール量の精度はヘッダーの動きの滑らかさに直結するのですが、プロトタイプを元にデザイナーに相談しならがら検証を進めていき、許容できる範囲と判断してSwiftUIを採用することにしました。

2. Introspectを使用し、ScrollViewの裏側のUIScrollViewのdelegateを使用する。

IntrospectはSwiftUIの裏側で使用されているUIKitにアクセスし、UIKitの機能を使用できるライブラリです。
これを使用すればSwiftUIのScrollViewでUIScrollViewを取得し、スクロール量を取得することが可能です。 しかし、SwiftUIの内部実装の変更によって裏側のUIKitを取得できなくなる可能性があることから、採用は見送りになりました。

PreferenceKeyとGeometryReaderで取得できるスクロール量の精度についての注意点

「UIKitのUIScrollViewをUIViewRepresentableで使用する」でも書きましたが、今回紹介した方法でScrollViewから取得できるスクロール量は、UIScrollViewから取得できるスクロール量より粗いです。

スクロール量の元にViewを動かすのが今回の紹介したStickyHeaderの実現方法のため、取得するスクロール量の精度が粗いとその分Viewのカクつきが気になってきます。
そのため、ヘッダーの動く範囲が大きくてカクつきが目立ってしまうような画面での使用はお勧めできません。
その場合、UIScrollViewを使用して実装するか、そもそもStickyHeaderが必要ないデザインで同じ機能を実現できないか考えることも必要だと思いました。

まとめ

今回紹介させていただいたSwiftUIでStickyなタブを実現する方法は、注意点こそあれど、SwiftUIで実現できる要件を増やす一つの手段になると思っています。 同じようにSwiftUIは使いたいけどタブを実現できるか分からないという方がいましたら、この記事がお役に立てると幸いです。

また、本記事で紹介した内容はほぼすべて同チームの@miichan_ochaが検証、実装してくれたものです。 @miichan_ochaを筆頭に、調査や検証に協力してくれた同僚の皆さんに感謝します。

*1:スクロール領域の最後まで行った時にそれまでのスクロール方向とは逆向きに小さくコンテンツが跳ねること

クックパッドが手がけるスマート冷蔵庫「マートステーション」の開発の歴史2023

こんにちは、クックパッドの齋藤です。 私はハードウェアPdMとして、生鮮食品のECプラットフォーム、クックパッドマートで事業に関わるハードウェア(マートステーション、プリンタ、温度監視システム等)の企画開発・開発ディレクション・調達・保守等を担当しています。

cookpad-mart.com

クックパッドマートはiOSおよびAndroidの専用アプリで利用可能なサービスです。このアプリで商品を購入して、街中のスマート冷蔵庫「マートステーション」で受け取れます。 マートステーションは駅やコンビニエンスストア、マンションの共用部といった人々の生活動線に設置し、共同納品を行っています。そのため自宅に個別配送をする他の生鮮食品ECに比べ、より物流コストを抑えた配送を実現しています *1

クックパッドマートは食材の輸配送や保管をはじめとした現実世界を相手に展開しているビジネスです。 そのためマートステーションや温度センサをはじめとした様々な機材が必要なこと、加えて社内にはハードウェアチームがあり、ハードウェアエンジニアや組込エンジニア等の普通のWeb系企業にはいない、ユニークな人材がいることを、前回下記の記事でお話ししました。

techlife.cookpad.com

本稿ではマートステーションの開発、進化の歴史をご紹介します。

前述したとおり、クックパッドマートでは駅やコンビニエンスストア、マンションの共用部等に設置し、配送した食材を保管するためのスマート冷蔵庫「マートステーション」を独自開発・運用しています。

マートステーションは単なる冷蔵庫として機能するのみならず、盗難防止用の電子錠や鍵として機能するQRコードのリーダー、さらには温度監視や死活監視の機能を搭載しています。

サービス開発初期、私たちの用途にかなう機材を探したものの、残念ながら世の中に存在しませんでした。従って自分たちで試行錯誤をしながら、以下のような開発を行っています。

techlife.cookpad.com ascii.jp www.wantedly.com

冷蔵庫の鍵は受け取りにきたお客様や配送ドライバー、メンテナンススタッフがQRコードを用いて開錠することができるようになっています。 また、すべての冷蔵庫に温度計を設置し、常に庫内温度をモニタリングしています。機材トラブルやドアの閉じ忘れ等、様々な要因による温度異常を検知した際には直ちに適切な対応が取れるようになっています。

庫内温度のモニタリングの様子

このようなハードウェアを開発するにあたって、私たちが得意としてきたソフトウェア開発とは異なるノウハウが必要であったため、チームで悪戦苦闘しながら、数多くの新規開発とアップデートを行ってきました。今回は私たちがその過程で獲得した開発に対する考え方と、2023年2月現在に活躍しているマートステーションのご紹介、そしてその進化の歴史についてご紹介します。

開発の方針

マートステーションは人々の生活動線上に設置先のオーナー等の承諾を得た上で間借りして設置されています。またその数は2023年1月末時点で一都三県に約1,000箇所にのぼるため、保守を行う上でも効率的に管理できることが求められます。 そのようなマートステーションを開発する上で重要となる開発方針を説明します。

ノーメンテナンスで安定稼働

メンテナンス要員が常に現場にいることを想定しておらず、その場で復旧および故障判断を即座に行いづらいマートステーションには、高い安定性が要求されます。 せっかくお客様に商品を購入していただいても、マートステーションにトラブルが発生するとお客様は商品を受け取ることができず、最悪の場合その日の食事の支度ができなくなってしまうからです。 しかしながら当初はハードウェア開発や管理等の知見が不足していたため、毎日不具合が発生しては現場にメンバーが向かって、復旧を行う必要がありました。お客様が購入した食材を受け取れない事態が発生することもありました。 ユーザ体験の著しい悪化を防ぐためにも、ステーションが安定して稼働していることは、とても重要です。

省スペース

たとえばコンビニエンスストア等の店舗では、既存の棚やラック等の店頭什器の代わりにマートステーションを設置するため、よりコンパクトなスペースで設置できることが求められます。スペースの都合で設置できないということも発生しうるため、よりコンパクトなマートステーションが開発できれば、それだけより多くの場所に設置できる可能性が広がります。

低コスト管理

マートステーションの「食材を冷やす」冷蔵庫機能については、冷蔵庫メーカの既存製品を用いています。 しかしながら、QRコードリーダや電子錠、温度監視、死活監視機能等、私たちがマートステーションとして運用する機材(以後コントロール・ユニットと表記します)およびシステムは、社内にて全体設計、機材選定、組込ソフトウェアの開発・運用を行っています。そのためコントロール・ユニットの変更を行えば、その分ファームウェアの更新や管理にかかるコストが増えてしまいます。極力同一のコントロール・ユニットで多様なモデルに適用できるように設計する必要があります。

コントロール・ユニット(JCM-Mk1)

より「やす」いマートステーションへ

上記に加え、開発では4つの「やすい」をキーワードに開発を行っています。機材を安定して低コストで調達するための方針です。

  • 調達しやすい:どれほど優れた機材でも、部品調達のリードタイムがかかってしまったり、市中在庫がなくなってしまっては、生産を行うことができません。特に現在の半導体をはじめとした部品不足により、マートステーションも調達リードタイムが伸びてしまい、欠品となるリスクに何度も直面しました。そのため、極力汎用品で代替調達が可能なものを部品として選定しています。
  • つくりやすい:部品点数が増えたり製造工程が複雑になってしまうと、組立ミスや組立コストの増加につながります。できる限り部品点数を削減し、つくりやすい設計を行うことで、コストを削減し、量産品質を安定させることができます。
  • 扱いやすい:品質の良いものを量産しても、設置後どうしても一定の割合で不具合が出てしまいます。最悪の場合、商品を購入したお客様が商品を受け取れなくなってしまう可能性もあるため、すぐに復旧できるよう保守チームにとって扱いやすい設計を常に意識する必要があります。
  • 価格もやすい:当然ではありますが、マートステーションは高額機材のため、1円単位の原価低減が非常に重要です。仕様設計についても常に優先順位をつけて、ある程度の割り切りを行う必要があり、高度なバランス感覚が必要となります。

現在設置されているマートステーションと開発の歴史

上記の考え方は最初から方針として打ち立てられていたものではなく、開発/量産/保守運用を行う過程やお客様からのフィードバックで得た気づきを、新たな開発や既存機への保守アップデートとして反映しながら身につけてきました。

本格量産を行えるマートステーション:JCM-Mk1/Mk2

当初マートステーションは市販のワインセラーを用いて製造していましたが、耐久性の問題もあり、業務用冷蔵ショーケースを用いて本格量産機と位置付けたのがJCM-Mk1とJCM-Mk2です *2 。少量生産を行いながら鍵の位置を変えたりするなど、カイゼンを行いながら生産を行い、量産ノウハウを蓄積しました。

耐結露性能の向上と、安定性の向上を目指して:JCM-Mk3/Mk3.1

JCM-Mk1/Mk2は2つの問題を抱えていました。1つは冷蔵ショーケースをベースとして量産していたため、一部の環境下で夏場結露に悩まされたことです。そのため、ガラス面に断熱材を貼り付けて結露の発生を抑えたり、結露水を受けるトレイを設置したりする等、追加対処を行うことで対応をはかる必要がありました。
次期モデルJCM-Mk3では断熱性の高いLow-Eガラスを用いることで、結露耐性を引き上げることとしました。

もう1つの問題はコントロール・ユニットの安定性が欠けていたことです。 マートステーションのメインコンピュータはRaspberry Piを採用していました。Raspberry Piはプロトタイプ検証や工場内の設備稼働状況の監視等、IoTの文脈で広く用いられています。しかしSoCのデータシートが一般公開されていなかったり等、遠隔地に大量に設置する機材に採用するには向いていない側面がありました。

加えてLTEモジュールやQRコードリーダ、独自開発の基板等を重ねた複雑な機材構成になっていて、不具合発生時の要因の特定が難しい構成となっていました。当初は突然機材からの通信が途絶え、現場対応に向かうもその複雑さから何が原因で不具合が発生しているのか、不具合の切り分けができないことも多々ありました。 ときには運用を中断してマートステーションごと交換するといった多大なリソースを必要とし、毎日のように不具合に悩まされました。

そのため、社内に構築した検証用機材で不具合の解析をしたり、現場投入前に改善策の品質をテストしたりしました。合わせて現場からのフィードバックをもとに、その1つ1つの事象に対して、保守チームとエンジニアが対処方法を議論し、機材構成のカイゼンや、ファームウェアの安定性を向上させるアップデートを行いました。

検証で用いるために構築した環境

たとえば、解析の過程で基板への入力電圧のわずかな不足が判明し、電圧を引き上げたり、冷蔵庫内温度データを解析し設定温度の最適化をしたり、様々なカイゼンを行いました。

機材アップデートの例

techlife.cookpad.com

マイナーチェンジモデルJCM-Mk3.1では上記のカイゼン結果を反映しつつ、当時起こりつつあった半導体や電子部品の不足も考慮したアップデートを考える必要がありました。最終的に内部構成の変更や、部品点数の削減、また調達しやすい部品を多く採用することで、機材の安定性を向上させ、原価を低減し、さらには調達リードタイムを減らすことができました。

このような活動を通して、現在はJCM-Mk1からJCM-Mk3.1まで、運用上機材の差をほとんど意識せずとも運用が行えるまでに、高い安定性を獲得しています。 2年前と比較し、現在マートステーションは3倍ほどに増えていますが、保守チームの人数およびコストは増やすことなく運用を行っています。

JCM-Mk3.1で開発したロゴ入りのオリジナル基板

冷蔵庫/制御システムを一新:JCM-Mk4

JCM-Mk3/Mk3.1で行った結露対策をさらに強化するため、ショーケースのドアを鉄板に変更し、ドアに結露対策のヒータを内蔵する等の更なる対策をほどこしたのが、JCM-Mk4です。 構造を大きく変えたため冷蔵庫本体は新しくPSE認証を取得しています。

またRaspberry Piを産業用IoTゲートウェイであるFutureNet MA-S110に一新、構成部品をゼロベースで見直すことで、更なる安定性の向上を行うことができました。

さらにコントロール・ユニットの点検用ドアを手前に引っ張り出せる仕様としました。従来はメンテナンス用に確保する必要があったヒンジドアの可動部のスペースを無くすことで、設置スペースのコンパクト化にも寄与することができたほか、最悪の状況ではユニットごと取り替えられるようになりました。

新開発の引き出し式点検ドア

デザインも一新し、クックパッドマートのアピールポイントである「こだわりの新鮮な食材をおとどけする」ことを強調したクリエイティブとして、サービス認知向上に寄与するものとしています。 現在、このクリエイティブのトンマナはポスター・のぼり等販促品にも引き継がれています。

次世代ステーション:HZK-Mk1

フルモデルチェンジ相当のJCM-Mk4で、全てが解決したわけではありません。

これまでのベース機は中国で生産を行っていたため、春節等で一斉に工場が止まってしまうと生産が止まってしまうほか、ある程度まとまったロットでそれなりのリードタイムを想定して見込み生産を行う必要があったため、過剰在庫を抱えるリスクがありました。

また、冷蔵庫そのものの安定性をより向上させる必要もありました。 庫内が冷えないといった不具合が発生した際、交換を行うための金銭的コストや、対応期間中マートステーションとしての営業を中断する機会ロスが発生してしまいます。それはお客様のユーザ体験を悪化させる原因にもなります。

そして、安定的な調達の観点から複数社より調達できるルートも検討する必要がありました。これを踏まえて業務用冷蔵庫の製造メーカに相談した結果、開発・量産の合意に至ったのがHZK-Mk1です。 本機は国内生産の業務用冷蔵庫をベースとし、冷蔵庫本機の信頼性の高さが最大の特徴です。実際に先行量産を行って設置を行いましたが、現在トラブル0でそれを実証しています。 また更なるコンパクト化を行い、設置ハードルをさらに下げることができています。 コントロール・ユニットについてもJCM-Mk4とほぼ同様のものを流用し、ファームウェアの保守管理コストを抑えました。

HZK-Mk1(開発中の機材です)

prtimes.jp

その他の試作開発

その他にも複数のメーカと試作開発を行い、さらなる開発の検討を行っています。その際もなるべく現状のコントロール・ユニットを用い、モデルが増加しても不用意に社内の管理コストを増やさないよう、配慮することを心がけています。

終わりに

現在マートステーションの設置箇所は1,000を超えています。そういった状況でも、管理コスト・保守コストを設置拠点数の増加に比例させず、より安心・安全によりよいユーザ体験でお使いいただけるよう、エンジニア/デザイナ/保守/ハードウェアPdM一丸となって、頑張っています。

もしご興味がある方がいらっしゃったら、是非こちらから採用情報をご確認ください!

cookpad.careers

*1:なお、一部有料オプションで自宅配送サービスもあります。

*2:弊社では冷蔵庫本体の製造メーカと開発世代を組み合わせて、形式名称を分類しています。ここではJCM社の冷蔵ショーケースとそれを用いた1世代/2世代目の形式のものをさすため、JCM-Mk1/Mk2という表記を行っています。

工事設計認証(技適)をとってみた

こんにちは、クックパッドの齋藤です。 私はハードウェアPdMとして、クックパッドマートで事業に関わるハードウェア(マートステーション、プリンタ、温度監視システム等)の企画開発・開発ディレクション・調達・保守等をやっています。

クックパッドマートとハードウェア

クックパッドマートは2018年9月20日にリリースされた生鮮食品のECプラットフォームです。リリースから4年以上経ち、新規事業ならではのスピードを維持しつつサービス拡大のため試行錯誤を日々続けています。

cookpad-mart.com

クックパッドマートはiOSとAndroidの専用アプリで利用可能です。このアプリで商品を購入して、近所の受け取り場所(マートステーションと呼んでいます)で受け取れます。有料で自宅配送するオプションもあります。

クックパッドマートでは、食材の輸配送や保管といった現実世界を相手にビジネスを展開しているため、冷蔵庫をはじめとした様々な機材が必要になるのですが、その中にはまだ世の中になく新しく開発する必要があったり、海外から調達する必要がある物がたくさんあります。

時には自分たちでオリジナルの基板を開発・量産することもあります。

そのため社内にはハードウェアチームがあり、ハードウェアエンジニアや組込エンジニア等、普通のWeb系企業にはいない、ユニークな人材がいます。

今日はその中でも、海外のデバイスメーカーから工事設計認証(技適)を自分たちで取得したお話をしたいと思います。

なぜ技適を取るのか

今クックパッドマートではチルド食材の配送時、シッパーという断熱ボックスの中に食材と蓄冷剤を一緒に入れて、軽自動車のバンなどで運んでいます。

バンの中にはいくつもシッパーが入っているのですが、食材の安全性を担保するため、シッパー内の温度が異常になっていないか監視を行っています。

現在はGPSマルチユニットSORACOM Editionという機材を用いて温度監視を行っていますが、サービスローンチから時間が経って事業規模が大きくなってきたため、より低コストで温度監視ができる仕組みが必要となってきました。

techlife.cookpad.com

そのためより効率的に配送中のシッパーの温度を把握し、異常を検知したり、万一品質不具合が出た時のトレーサビリティを確保する仕組み “TemperatureRightHear”、通称「TempRa(テンプラ)」を開発しています。

ざっくりとしたポンチ絵

開発するものとしては、

  1. シッパーの中に入れ、温度センサーが取得する値をBLEで送信するビーコン
  2. バンのシガーソケットに刺し、1のデータを受信してLTE経由で送信する車載IoTゲートウェイ
  3. 2のデータを集計分析し、蓄積したりアラートを促すバックエンドシステム

の3つがありますが、現在マートでは配送バンを数多く運用しており、その時のシッパーは膨大な量になります。

1で用いる市販のビーコンは、1個あたり9,000円程度してしまいます。配送時には数千ものシッパーを用いるため、普通に調達してしまうと数千万円もの高額出費を覚悟する必要があります。

そこでまず、必要な個数を減らせないか考えました。実はこのビーコンは既にマートステーションでの温度監視に用いていたため、これを回収して転用することで新規調達の個数を減らす計画です。マートステーションの側ではより安価な有線の温度センサを利用するように変更します。余談ですがこの有線の温度センサも新規開発を行いました。

しかしながらそれでも足りないため、今回直接中国の深圳にあるメーカーから現地のビーコンを直接購入し、調達価格を抑えることとしました。 その場合1個あたり15米ドル程度で調達することができるため、1個あたりの差額は7,000円程度安く調達することができます。そのため大幅に調達価格を抑えることができるのです。

であればはじめからそうすればいいじゃん、となると思いきや、そうは問屋がおろしません。 日本で電波を発生する機器を使用する際は、「技術適合認証」(通称技適)を取得する必要があります。技適を取得していない機器で電波を発した場合、電波法違反という法令違反となります。

詳細な説明はこのブログ https://www.musen-connect.co.jp/blog/course/other/japan-radio-law-basic/ がわかりやすいです。

これを取得するのは結構面倒くさいですし、それなりに電気や電波に関するエンジニアリングの知見が必要なため、なかなか大変だったりします。

しかしながらクックパッドマートには、強力なハードウェアエンジニアがいますし、私自身もマートステーションの設置をするときや将来的に物流倉庫等をIoT化するときに使えるかと思い「第一級陸上特殊無線技士」という電波についての資格も取っていました。そこで認証機関に相談したところ3-50万円で取得できることがわかり、将来的なクックパッドの知見にもなると思ったので、社内で相談した結果、量産するもの1つ1つに技適を適用するときに用いる「工事設計認証」を取得した上で調達を行うこととしました。

取得の流れ

取得に当たっては、具体的に下のような流れですすめていきました。

  1. デバイスの調達元からデータシート、金額見積等を照会します。 見積はサンプル費用、正式調達時の数量における単価と総額の2つにわけてもらいます。予算上問題がないことを確認し、サンプルを調達しました。

    サンプルデバイス

  2. サンプルデバイスについて『技適未取得機器を用いた実験等の特例制度』の届出をし、動作や私たちの要求仕様を満たしているか確認します。 制度の詳細は https://www.tele.soumu.go.jp/j/sys/others/exp-sp/ をご確認ください。

  3. 認証機関に相談の上、申請書類を作成します。 認証期間はいくつかあるのですが、今回は老舗である「一般財団法人テレコムエンジニアリングセンター」(通称TELEC)に依頼しました。 書類作成は結構大変だったのですが、先方はとても親切で、細かい表現に至るまで細かくフィードバックいただけました。それだけ書類の「てにおは」含め細かく校正が必要で、このフィードバックがなければ書類作成ができませんでした。

  4. 認証機関で試験を行います。 試験の際はサンプルデバイスとは別にテスト用のデバイスが必要です。このデバイスはスペクトルアナライザ等に接続するためRF出力ができる必要があり、また先方の安定化電源に繋げられる必要があります。このテストデバイスを用いて、入力電圧などを変えながら挙動を確認する試験を行っていただきました。

取得費用

デバイスの仕様によって異なりますが、今回はBLEのみのデバイスということで、約30万円が費用としてかかりました。

申請書類

主な申請書類は認証申込書・別紙資料、工事設計書、無線設備系統図、確認方法書、 部品配置図又は写真、外観図又は写真です。詳細は https://www.telec.or.jp/services/tech/offer.html をご確認ください。 無線設備が1チップになっていたりしたときはどうするのか、その半導体の詳細構成が開示されてない場合はどうするのか、等細かい不明点が山のように出てきますし、製造元とのやりとりもいろいろ発生します。

面白かったのは、部品が容易に変えられないことを説明するやり取りです。調達予定のデバイスは普通のプラスドライバーでケースが開いてしまうのでそこがいけないのかと思い、ネジが保護ゴムで覆われていて容易には開けられないということを書いたところ、「部品が表面実装部品で構成されている」と書けばよいとのことでした。この辺のニュアンスは、慣れていないと全く分からないですね……。

試験当日

ここから書類の修正作業をしたりテストデバイスをメーカーから取り寄せたりに10営業日くらい使いました。申請書類がOKとなれば、試験当日です。 それまでにテストデバイスの制御用ソフトウェアの動作確認や、デバイスとの接続確認をした上で、試験機関を訪問してテストです。 テスト中デバイスが想定外の挙動をするなど、ヒヤヒヤする場面もありましたが、なんとかみんなのファインプレーで切り抜けることができました!

審査と認証書交付

その後先方内で審査があり、終了後1週間程度で無事認証書が交付されました! やったね!

実際の認証書

正式調達

実際に交付された認証番号をデバイスに記載した上で、ようやく正式調達です! せっかくなので、ロゴと弊社ミッション “Make everyday cooking fun!” も書いておいてもらいました!

正式調達のビーコン

終わりに

ということで、今回クックパッドではじめて、工事設計認証の取得を試みてみました。 今までマートのハードウェアチームはステーションの開発やプリンタの開発等、リアルワールドで仕事をする上で欠かせないハードウェアを開発・量産・保守してきました。 もちろん今回のデバイスはスクラッチ開発ではないですし、なんなら別メーカーにて日本に導入済みのデバイスです。 しかしながら今回取得した工事設計認証も、そういったハードウェアを扱えるチームがあってこそ、無事取得まで漕ぎ着けることができたのは間違いありませんし、 社内ブログ「Groupad」にて知見共有が積極的に行われていたので、そういったクックパッドならではの技術的知見で、はじめてのことでもチャレンジすることができました。 取引先との雑談でそういった話が出た時には、「そこまでやるんですか!」と言われることも多いです。

このような形で、ハードウェアチームはユーザの皆様に安全・安心で高品質な食材をお届けするという、「あたりまえのことを」「あたりまえに」実現するために日々開発や保守をおこなっています。

もしご興味がある方がいらっしゃったら、是非こちらから採用情報をご確認ください!

cookpad.careers