Rails に Babel と Rollup を組み込んで CoffeeScript を JavaScript に段階的に移行した話

こんにちは。技術部クックパッドサービス基盤グループの青沼です。当グループではクックパッドのレシピサービスを支える web アプリケーションの改善を進めています。今回はフロントエンドの改善の一環として、 Babel と Rollup を Rails のアセットパイプラインに組み込み、レガシーな CoffeeScript ファイルを ES2015+ の JavaScript に移行した話をします。

レシピサービスと CoffeeScript の歴史

クックパッドは10年以上の歴史を持つサービスです。中でもレシピサービスの web アプリケーションは初期に作られた Rails 2 アプリケーションがアップグレードを重ねながら今も動いています。2018年には Rails 3 から4へ、つい最近では4から5へのアップグレードを完了しました。 Ruby のコードはそれに伴って新しい書き方へと徐々に移行されてきましたが、「壊れていないものは直すな」という言葉もあるように昔から姿を変えていないコードもあります。その一角がビューで使われる CoffeeScript のコードです。

CoffeeScript はいわゆる AltJS (JavaScript に変換される言語)のはしりで、2012年ごろに流行しました。当時の JavaScript は今のように毎年仕様改訂されることがなく、進化が止まったように見えていました。その停滞した空気に新鮮な風を吹き込んだのが CoffeeScript です。Ruby や Python や Haskell から影響を受けた簡潔な記法を取り入れ、リスト内包表記などの新しい言語機能を実装しました。今では JavaScript に取り入れられているスプレッド構文 [1, 2, ...rest] やアロー関数 () => {} は CoffeeScript が実装した機能から影響を受けています。Rails はバージョン3.1から CoffeeScript をフレームワークに統合し、新しい JavaScript コードは CoffeScript で書くように推奨していました。レシピサービスでも CoffeeScript のコードが多く書かれました。

いっときブームを起こした CoffeeScript でしたが、 JavaScript 自体がそれなりに書きやすく進化し、 TypeScript など有力な AltJS が台頭してきた現在では、積極的に採用する理由はほとんどなくなってしまいました。むしろ「JavaScript や AltJS の進歩から取り残されている」「既存のコードを変更するために CoffeeScript を覚える・覚え直すのが面倒」「Rails 自体がもはや CoffeeScript に力を入れていない」などのデメリットが目立つようになっています。

さらば CoffeeScript

そこで、2019年12月、レシピサービスに残る CoffeeScript をすべて JavaScript に変換することを決めました。元々 JavaScript に変換することが前提の言語ですから、変換自体は特に難しいものではありません。ただし純正のコンパイラの出力はコメント行が残っていなかったりと読みやすさを優先したものではないので、素直で読みやすい JavaScript を生成する別のコンパイラ、 decaffeinate を使うことにしました。(脱 CoffeeScript を支援するツールにデカフェと名付けるセンスがいいですね。)実際にはファイルひとつひとつを手作業で変換していくのではなく、一括変換を実行してくれるサポートツール bulk-decaffeinate を使います。 bulk-decaffeinate は以下の順番で変換対象に指定したファイル群を処理します。

  1. ファイルをエラーなく変換できるかをまずチェック。エラーがあれば中断
  2. ファイルをコピーしてバックアップを作成
  3. 拡張子を .coffee から .js に変更して Git にコミット
  4. ファイルを decaffeinate で JavaScript に変換し、上書き保存して Git にコミット
  5. 変換後のファイルに ESLint を適用して整形し、上書き保存して Git にコミット

拡張子の変更だけを先に行ってコミットするのは地味ですが重要なポイントです。内容の変更とファイル名の変更を1つのコミットに混ぜてしまうと、 Git は変更前後のファイルの関係を推測できず、変更前のファイルが削除されて新しいファイルが追加されたものとみなします。これではファイルの履歴が途切れたように見えてしまいます。内容の変更とファイル名の変更を別のコミットに分ければ Git はファイルの履歴を正しく推測してくれます。(Git がコミットとして記録するのはファイルツリーのスナップショットだけです。ファイル名が変更されたことを表すデータは存在しません。2つのコミットのスナップショットを比較して、同じかほとんど違いがない別名のファイルがあれば、ファイル名が変更されたとみなして表示します。)

サポートブラウザの問題

2020年1月から、 bulk-decaffeinate を使ってページや機能ごとの単位で徐々に変換を進めていきました。変換後の JavaScript をそのまま使えるなら楽なのですが、残念ながら一筋縄ではいかないことが分かりました。変換作業を行った当時のレシピサービスは InternetExploler 9 をサポートしていたからです。(今では IE 11 までのサポートになりましたが、これでもまだレガシーですね。) decaffeinate が書き出す JavaScriptは IE 9 で動かない ES2015 以降の新しい構文を含みます:

function foo() {
  const x = 1; // const は使えない

  var y;
  for (y of [1, 2, 3]) { ... } // for-of は使えない

  sendAPIRequest('/example', (result) => { // アロー関数は使えない
    if ([4, 5, 6].includes(result.items)) { ... } // Array.includes は使えない
  });
}

これらを手作業で書き換えることもできますが、人間が新しい構文を見分けるのはミスを起こしがちですし、遠からずサポート対象外になる IE 9 のためだけに古い構文に書き直すのはもったいないものです。なんとか自動的に JavaScript を IE 9 に対応させられないだろうかと考えました。さて、 JavaScript を昔のブラウザに対応するよう変換するといえば Babel の出番です。 Babel を使えば新しい構文をターゲットのブラウザに対応した構文に変換することができます。

ところで、 JavaScript は仕様が改訂を重ねると構文の追加だけでなく新しいクラスやメソッドが追加されることもあります。それらはソースコードの変換で昔のブラウザに対応した形に書き換えることはできません。クラスやメソッドは動的に扱われる(たとえば実行時に組み立てた文字列を名前としてメソッドを呼び出すことができる)ので、ソースコードを静的に解析しただけでは網羅できないからです。昔のブラウザで動かそうとすれば、新しいクラスを再現する polyfill と呼ばれる一群のコードを追加で導入する必要があります。しかしながら、 IE 6 までサポートする巨大な polyfill を丸ごと入れてしまうと web ページのサイズとロード時間に影響してきます。かといって不要なパーツを除外していくのも手間がかかります。

幸いなことに、 decaffeinate が生成したコードで polyfill が必要になるのは Array.includes() ほか少しだけで、1万行のうち数十行程度でした。今回はそこだけ polyfill がいらない形に手作業で書き換えることで対処しました。

Babel をアセットパイプラインに組み込む

話を Babel に戻すと、 JavaScript をいつ Babel で変換するかという問題が出てきます。 Rails は Rails で JavaScript のアセットを変換するアセットパイプラインを持っています。 Rails アプリケーションをデプロイするときにアセットをコンパイルするタスクを実行すると、アセットパイプラインによって入力ディレクトリ以下の JavaScript ファイルが読み込まれ (CoffeeScript ファイルなら読み込んだ後に JavaScript に変換するプリプロセスが行われ)、 JavaScript 同士が結合され、最後に圧縮されて出力ディレクトリに書き出されます。また開発モードで Rails サーバを起動しているときは、入力ディレクトリ以下のファイルが監視され、編集されたアセットは自動的に再コンパイルされます。

この一連の処理に Babel を付け加えるなら、入力ディレクトリ内のファイルを先に書き換えるか、出力ディレクトリ内のファイルを後で書き換えるかです。どちらにせよ開発モードで自動再コンパイルのタイミングと干渉しないようにする必要があり、ちょっと面倒な予感がします。

ここでアセットパイプラインの処理をもう一度よく見てみると、「CoffeeScript ファイルなら読み込んだ後に JavaScript に変換するプリプロセス」が目にとまります。アセットパイプラインにはソースコードを別のソースコードへと変換する処理がすでに組み込まれているのです。この仕組みに乗せてしまえば、ビルドツールを用意しなくても CoffeScript が使えていたのと同様に、 Babel が裏で動いていることを意識しなくてよくなるはずです。

アセットパイプラインに新しい変換処理を組み込むには、 call(input) クラスメソッドを持つクラスを書いて Sprockets.register_postprocessor で登録するだけと、拍子抜けするほど簡単です。実際に書いたのがこのコードです:

# babel_processor.rb
module BabelProcessor
  BABEL_PATH = Shellwords.escape(Rails.root.join("node_modules/.bin/babel").to_s)

  class Error < StandardError; end

  def self.call(input)
    # data は JavaScript のソースコード文字列
    data = input[:data]

    if has_babel_pragma?(input)
      # data を babel コマンドの標準入力に流し込んで標準出力から結果を得る
      stdout, stderr, status = Open3.capture3("#{BABEL_PATH} --no-babelrc --no-highlight-code", stdin_data: data)
      raise Error, "in #{input[:filename]}: #{stderr}" unless status == 0
      data = stdout
    end

    { data: data }
  end

  # ファイルの先頭に // @babel か /* @babel */ があるときだけ変換
  def self.has_babel_pragma?(data)
    %r{\A \s* /[/*] \s* @babel\b}x.match?(data)
  end
end

# config/application.rb の中で
Sprockets.register_postprocessor 'application/javascript', ::BabelProcessor

BabelProcessor はひとつの変換処理を担当するアセットプロセッサとして振る舞うクラスです。 call(input) メソッドはファイルから読み込まれた JavaScript のファイル名とソースコード文字列を受け取ります。その文字列を babel コマンドの標準入力に渡し、変換後の文字列を標準出力から得て返すだけです。これをアセットパイプラインのポストプロセッサとして登録します。なお、アセットパイプラインのメイン処理で結合された後の JavaScript を扱うため、プリプロセッサではなくポストプロセッサにしています。また変換されることで壊れるコードがないとも限らないので、選ばれたファイルだけを変換するため、先頭に // @babel と書かれたファイルだけを変換するようにしています。

この仕組みはうまく動き、移行作業はスムーズに進みました。 bulk-decaffeinate によって出力された ES2015+ の JavaScript はほとんど修正の必要がなく、1ヶ月程度で合計1万行程度の CoffeeScript をすべて JavaScript に変換することができたのです。

JavaScript と CoffeeScript の行数の変化
JavaScript と CoffeeScript の行数の変化

CoffeeScript の行数が減るにつれて JavaScript の行数が増えています。(並行して不要な JavaScript を削除する作業も進めていたので2月13日あたりで行数が急激に減っていますが、無関係です。)

Babel から Rollup へ

CoffeeScript と jQuery で書かれていたコードが ES2015 になるともっと欲が出てきます。 Rails 独自の //= require foo.js ディレクティブで文字列的にファイルを結合するのをやめて import 'foo'require('foo') を使いたいし、 npm でインストールしたモジュールも使いたいし React と JSX でビューを書きたいし、そうなったら TypeScript も使いたい。

Rails 6 に統合された webpack を使えば実現できるのですが、あいにく Rails 4 では動きません。それにレシピサービスはページごとに異なる小さな JavaScript ファイルをいくつも読み込んでいて、コードをひとまとめにバンドルするのが基本の webpack とは相性が悪いのです。たとえば、ビューの部分テンプレートの中で特定の条件のときだけ <script> タグを差し込む以下のような処理が存在します:

/ _footer.haml

.footer
  %p ページのフッターです
  - if some_condition?
    %p 条件に一致したときだけ表示される追加のフッターです
    / 追加のフッターにイベントハンドラを追加する JS
    = javascript_include_tag 'optional_footer.js'

この optional_footer.js をバンドルにまとめるとすると、条件に一致するときだけ処理を実行するようにロジックを変える必要があります。ひとつならともかく何十箇所もこんなコードがあるので修正の手間も馬鹿になりません。

なんとかならないかと思っていたら、モジュールバンドラの Rollup がコマンドラインで単一のファイルを変換できることに気づきました。 webpack よりは若干マイナーな存在ですが、つくりがシンプルなぶん設定が簡単で、コマンドとして実行しても動作が速いです。上記の BabelProcessor をベースに、 babel コマンドを呼ぶ箇所を rollup にし、いくつかの処理を加えました。またその処理に対応する Rollup の設定を rollup.config.js に書きました。

# rollup_processor.rb
require "tempfile"

module RollupProcessor
  ROLLUP_PATH = Shellwords.escape(Rails.root.join("node_modules/.bin/rollup").to_s)
  ROLLUP_CONFIG_PATH = Shellwords.escape(Rails.root.join("rollup.config.js").to_s)

  class Error < StandardError; end

  def self.call(input)
    data = input[:data]
    if has_rollup_pragma?(data)
      self.build(input[:data], input[:filename])
    else
      { data: data }
    end
  end

  def self.build(data, filename)
    dirname = File.dirname(filename)

    Tempfile.create("cookpad_all_rollup_processor") do |temp|
        stdout, stderr, status = Open3.capture3(
          { "COLLECT_MODULE_PATHS" => temp.path },
          "#{ROLLUP_PATH} --config #{ROLLUP_CONFIG_PATH} -",
          stdin_data: data,
          chdir: dirname,
        )
      raise Error, "in #{filename}: #{stderr}" unless status == 0


     # Rollup に渡した JavaScript から `require()` や `import` で読み込まれたファイルのパスを集め、
     # それらのファイルが依存関係にあることを Rails に伝える。
      module_paths = JSON.parse(temp.read)
      dependencies = self.dependencies_from_paths(module_paths, dirname)

      { data: stdout, dependencies: dependencies }
    end
  end

  def self.dependencies_from_paths(paths, base_dir)
    node_modules_path = Rails.root.join("node_modules").to_s + "/"
    paths.reject do |path|
      path == "-" || path.start_with?("\0") || path.start_with?(node_modules_path)
    end.map do |path|
      realpath = File.realpath(path, base_dir)
      "file-digest://#{realpath}"
    end
  end

  def self.has_rollup_pragma?(data)
    %r{\A \s* /[/*] \s* @rollup-entry-point\b}x.match?(data)
  end
end

# config/application.rb の中で
Sprockets.register_postprocessor 'application/javascript', ::RollupProcessor
// rollup.config.js
import babel from '@rollup/plugin-babel';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';

import fs from 'fs';
import path from 'path';

const collectModulePaths = {
  buildEnd() {
    if (process.env.COLLECT_MODULE_PATHS) {
      const modulePaths = Array.from(this.getModuleIds());
      fs.writeFileSync(
        process.env.COLLECT_MODULE_PATHS,
        JSON.stringify(modulePaths)
      );
    }
  },
};

export default {
  output: { format: 'iife' },
  plugins: [
    commonjs(),
    resolve({
      browser: true,
      extensions: ['.js', '.jsx', '.mjs', '.ts', '.tsx'],
    }),
    babel({
      configFile: path.resolve(__dirname, 'babel.config.js'),
      babelHelpers: 'bundled',
      extensions: ['.js', '.jsx', '.mjs', '.ts', '.tsx'],
    }),
    collectModulePaths,
  ],
};

追加した処理はアセット間の依存関係に関するものです。 Rails のアセットパイプラインでは、 JavaScript のファイルから別の JavaScript を読み込むディレクティブ //= require を書くことができます。たとえば a.js から b.js を読み込むには

console.log('This is a.js');
//= require ./b.js
console.log('This is b.js');

と書き、生成される a.js の中身は

console.log('This is a.js');
console.log('This is b.js');

になります。このような //= require が書いてある参照元は参照先に(a.js は b.js に)依存することになります。依存関係があることで、開発モードで Rails サーバを起動しているとき、 b.js をエディタで編集すると、 b.js だけでなく参照元の a.js も自動的に再コンパイルされます。

しかし、 Rollup をアセットプロセッサとして使うと依存関係が失われてしまう場合があります。たとえば Rollup で処理される c.js から d.js を読み込むには

console.log('This is c.js');
import './d';
console.log('This is d.js');

と、 JavaScript の import 文を使うこともできます。アセットパイプラインの視点で見ると、 rollup コマンドに c.js の中身を流し込んだら

console.log('This is c.js');
console.log('This is d.js');

が得られたということになります。 Rollup によって d.js がインポートされたことは知る由もないので、 c.js が d.js に依存するという情報は失われます。

そこで依存関係を正しく認識させるために Rollup のプラグインを書きました。 rollup.config.js の collectModulePaths の部分です。 Rollup はプラグインで簡単に拡張でき、ビルドのさまざまなフェーズに独自の差し込むことができます。 collectModulePaths プラグインはビルド後のフェーズでビルド中にインポートされたパスを集めてファイルに書き出します。それをアセットプロセッサ側で読み込み、依存関係データを構築しています。

こうして Rails に Rollup を組み込んだ結果、アセットパイプラインを前提に書かれた既存の JavaScript コードにまったく手を加えることなく Rollup の恩恵を受けられるようになりました。 JavaScript の中で他のスクリプトやモジュールを import することができますし、そうしたければ //= require ディレクティブと混ぜて書くことさえできます。

import React from 'react';
//= require ./e.js
import './f';

何やら禍々しい見た目ですが、段階的にコードを改善していくには便利な仕組みです。

おわりに

CoffeeScript とお別れした話と、アセットパイプラインの一工夫でフロントエンド開発がちょっとモダン化した話をしました。レガシーな JavaScript コードを抱えた Rails アプリケーションを運用している皆さんの参考になれば幸いです。

クックパッドでは仲間を募集しています!

さて、歴史ある web サービスの改善は地道なものですが、ときにはエキサイティングで急激な変化もあります。クックパッドサービス基盤グループでは、まずスマートフォンブラウザ向けのレシピページから、 Next.js と GraphQL バックエンドを使って一から書き直すプロジェクトを進めています。実はすでに一部のスマホ向けレシピページは Next.js で表示されています。今後もさらに多くのページを改善していきますので、最新の web 技術をバリバリ使ったサービス開発に興味がある方も、レガシーコードをバタバタやっつけたい方も、ぜひお気軽にご連絡ください。

info.cookpad.com

Cookpad Online Spring Internship 2021 を開催します!

ユーザー・決済基盤部の三吉(@sankichi92)です。昨年よりエンジニアの立場から新卒採用を担当しています。

この記事では、3月下旬に開催するスプリングインターンシップについて紹介します。 インターンシップの実施要項や応募フォームは下記のページよりご確認ください。

クックパッドでは、スプリングインターンシップを毎年開催しています。 しかし、その形式は年々改善を重ね変わってきています。 昨年は初のオンライン開催でした。 今年もオンラインでの開催ですが、社内ハッカソン "Hackarade" の形式を取り入れた新しいものになっています。

また、昨年のテーマはサーバサイドアプリケーションのパフォーマンスチューニングでしたが、今年はモダン Web フロントエンドがテーマです。

クックパッドの社内ハッカソン "Hackarade" を体験してみよう!

クックパッドでは年に数回、エンジニア全員参加の社内ハッカソン "Hackarade" を開催しています。 Hackarade は単なるハッカソンとちがい、エンジニア全員で新技術に触れることが目的です。 そのため毎回テーマを変えており、社内の第一人者による講義を受けたのち開発に取り組みます。 詳しい様子は以下の開催レポートをご覧ください。

もともとの Hackarade は1日で完結するものですが、昨年のスプリングインターンシップでは1日は短すぎるという声をいただいたので、5日間の日程を2ターム用意しています。

  • 第1ターム: 3/15(月)〜3/19(金)
  • 第2ターム: 3/22(月)〜3/26(金)

5日間という期間を取っていますが、時間を合わせて集まるのは初日と最終日のみです。 初日にオリエンテーションを行い、最終日に成果発表を行います。 2日目から4日目は各自での開発の期間とし、時間の使い方は自由です。 研究室やバイトに行ったり、別のイベントに参加したりしていただいても構いません。 その間、Slack や必要に応じて Zoom で社員 TA が非同期的に質問の回答や開発のサポートを行う予定です。

オフィスに集まる形だと全日程の参加が前提なので、こういった形式が取れるのもオンラインならではかと思います。首都圏以外の地域や海外からの参加など、オフィスに来ることが難しい方でも、より参加しやすくなっています。 他にもオンラインならではの工夫を凝らしたいと考えています。

実践! モダン Web フロントエンド開発

Hackarade では毎回テーマを変えていると書きましたが、今回のテーマは「実践! モダン Web フロントエンド開発」です。 技術部の外村 @hokaccha が講師を務めます。 TypeScript、React、Next.js、GraphQL など、クックパッドでも利用している Web フロントエンドの技術について実践を通して学びます。 参加に際して Web フロントエンドの知識は問いませんが、これらの技術にすでに親しみのある学生も(もちろんそうでない学生も)楽しめる内容になるはずです。

クックパッドのフロントエンド事情については、外村による以下の記事をご覧ください。 この記事をきっかけに今回のテーマが決まりました。


スプリングインターンシップの応募締切は 2/26(金) 3/3(水) 13:00 です。 選考フローは、書類選考とプログラミング課題のみのシンプルなものになっています。

学生の皆さまのご応募をお待ちしています!

SwiftUI を活用した「レシピ」×「買い物」の新機能開発

レシピ×買い物の新機能開発とSwiftUI VIPER アーキテクチャへの部分的導入とサービス開発の効率変化

こんにちは。クックパッド事業本部 買物サービス開発部の藤坂(@yujif_)です。

2020年10月にクックパッド iOS アプリで「買い物機能」をリリースしました。今回はこの新機能の開発にあたって考えたことや取り組みについてご紹介します。

買い物機能の画面例

買い物機能とは

生鮮食品EC「クックパッドマート」の仕組みと連携し、レシピサービス「クックパッド」のアプリから食材を注文できます*1。これはただクックパッドマートの機能を使えるだけ、というわけではありません。「レシピ」と「買い物」が融合するからこその良い体験づくりを目指しています。

詳しい内容はプレスリリースクックパッドでお買い物 - 地域限定機能をデザインする上で考えたこと- にもまとまっていますので、ぜひあわせてご覧ください。

info.cookpad.com

note.com

レシピから買い物へ

レシピから直接材料を買うこともできる

レシピサービスならではの良さとして、例えば「材料欄からスムーズに買える」という便利さがあります。作りたいレシピが見つかったとき、必要な食材をすぐに買い揃えられます*2

買い物からレシピへ

逆に、買い物からレシピへの流れもあります。

気になる食材の楽しみ方がすぐ見つかる

クックパッドマートの仕組みによって、精肉店や鮮魚店などの専門店や地域の農家など、さまざまなお店・生産者の魅力的な食材をアプリから一覧できます。

食材を眺めていて「へぇ〜こんなの買えるんだ!」「どう食べるのが良いのだろう?」と気になったら、すぐに自分好みの食べ方・楽しみ方を、クックパッドに集まる沢山のアイデアの中から見つけられます。

「明日はこれ作ろう!」「週末はこれが食べたいな〜」とワクワクしながら注文し、次の料理が楽しみになる、そんな素敵な時間を作れるかもしれません。

実は SwiftUI で作られている

買い物機能の画面は、そのほとんど全てが SwiftUI で実装されています。

SwiftUI は iOS / macOS アプリを構築できるフレームワーク*3です。2019年の WWDC で Apple から発表されました。従来の UIKit と比べて、より簡潔なコードで UI を組める点が特徴的です。

  • 利点
    • UI を作りやすく、生産性が向上する
    • 将来的に標準化しうる SwiftUI にキャッチアップできる
    • UIKit と組み合わせて使える
    • Dynamic Type、Dark Mode など iOS の最近の機能が考慮されている
  • 欠点
    • まだ挙動に不具合が残っている部分もある
    • iOS 12 以前では使えない
    • UIKit に比べると機能が足りていない
    • 知見を持つエンジニアがまだ限られている

SwiftUI はまだ比較的新しい技術であり、上記のようなメリットとデメリットが考えられます。ゼロから作る新規アプリではなく、長い歴史もあって大規模なクックパッドアプリ*4で、なぜ SwiftUI を本番投入することにしたのでしょうか?

技術選定の背景

1. 本番で早く検証し、サービス開発の効率を上げたい

「レシピ」と「買い物」を融合して、毎日の料理を楽しみにすること、それがこの買い物機能をつくる目的です。

最終的に目指す先は決まっていても、どういうコンセプトが最も良いのか、どのような機能・UI ならコンセプトを実現できるのか、具体的な形はまだ誰にも分かっていません。何度も作り変えながら模索していくことになります。

実生活の中で使って発見を増やす

新しいアイデアの検証では、最小限のプロトタイプを作り、ユーザーインタビューをして判断することが一般的です。時間を費やしすぎずに重要な知見を得られる点がメリットですが、それだけでは不十分な面もあると経験上感じています。

実際にアプリを使っていると、開発中やインタビュー中など当初は気づいていなかった価値や問題を実感して気持ちが変わることがあります。

実生活で試してこそ課題や価値が分かる

自宅のリビングやキッチン、通勤中、送り迎えや買い物など、現場にいる当事者だからこそ、課題に対する解像度も高く「どうなっていたら嬉しいのか?」という解決策が自然と浮かぶこともあり、結果的に質の高いフィードバックが得られやすいと思っています。

「レシピ」と「買い物」を組み合わせた価値を追求していくためには、瞬発的な検証だけでなく、日常的に使ってなるべく深い発見を重ねて、両者で補完しながらユーザー理解を精緻化していくことが一層重要ではないかと考えていました。

そのためには、実際のアプリを使って本番といえる環境で素早く検証を積み重ねられることが必要になります。

UI の「作って壊し」をやりやすく

では、どうすれば素早くアプリを本番で試せるのでしょうか。今回の場合、UI 実装を速くすることが効果的に思えました。

買い物機能は完全に新規のアプリケーションというわけでもないので、バックエンドはクックパッドマートの API を使えるなど、基礎部分がある程度固まっていました。そのため、レシピアプリ側でまず最低限使える状態にした後、改善を繰り返す期間が焦点となってきます。

そのタイミングで最も変動が大きいのは UI ではないかと思います。

使うデータは同じでも、目的に応じて見せ方は変わる

サーバーから返す情報が同じままでも、見せ方を変えるだけで印象や体験は変えられます。時には API からまるごと変える場合もありますが、多くは UI(View 層)の作り直しが楽になるだけでも実装は速くなります。

このように UI の「作って壊し」を頻繁に繰り返しやすい仕組みを用意したいと考える中で、SwiftUI が候補の一つとして挙がりました。

2. SwiftUI のリスクを抑えつつ導入できる見込みがあった

クックパッド iOS アプリでは 2メジャーバージョンをサポート

クックパッド iOS アプリの対応方針

買い物機能のリリース時期は2020年後半を予定していました。例年通りなら iOS 14 がリリースされる頃です。クックパッド iOS アプリは最新2メジャーバージョンをサポートする方針で、iOS 13 と 14 に向けた体制となります。そのため、iOS 12 以下で SwiftUI を使えないことはそれほど大きな問題ではありませんでした。

機能・画面単位で切り分けやすいアーキテクチャ

クックパッド iOS アプリの1画面のアーキテクチャ(https://logmi.jp/tech/articles/321186

クックパッド iOS アプリでは数年前から VIPER ベースの Layered Architecture を採用しており、責務ごとにしっかりと実装が分かれていました。また、マルチモジュール化*5も進めており、大きな機能はモジュール単位で分離されているため、他の機能開発にも影響せず実装を進めやすいという点もありました。一部だけ SwiftUI を導入するのも容易な環境だったと言えます。

【方針】View 層のみで SwiftUI を部分的に導入する

これらの状況を総合して、メリットを生かしつつリスクを最低限に抑えられる形であれば、SwiftUI を導入するのは良い選択だと考えました。

具体的には、既存の VIPER アーキテクチャに適合したまま、View 層のみで SwiftUI を使うという形です。SwiftUI には画面遷移を行うための NavigationView などのコンポーネントもありますが、それらは使わずにあくまで素朴な UI コンポーネントのみを使います。

  • 使う例:Button, Text, VStack, HStack, ZStack, ScrollView
  • 使わない例:NavigationView, List, Form, TextField*6

画面単位で完全に切り分けられているため、もし SwiftUI で実装に困難が生じたらすぐにそこだけでも UIKit に戻せるというリスク対策になっています。

実装

既存のVIPER アーキテクチャへの SwiftUI の組み込み

SwiftUI を組み込んだVIPER View 層の概略図

基本的には、画面(VIPERシーン)ごとに従来通り UIViewController があり、そこに橋渡し役の UIHostingController を介して、SwiftUI で書かれた View が置かれる形です。

ここで、SwiftUI.View へデータを流し込むためには DataSource、SwiftUI.View からユーザー操作等のイベントを伝えるためには Delegate をそれぞれ用意して渡しています(詳しくは後述)。

final class KaimonoCartViewController: UIViewController,
    KaimonoCartViewProtocol, 
    KaimonoCartViewDelegate {

    ……

    private var dataSource: KaimonoCartView.DataSource = .init()

    override public func viewDidLoad() {
        super.viewDidLoad()

        let rootView = KaimonoCartView(delegate: self, dataSource: dataSource) 
        let hostingVC = UIHostingController(rootView: rootView)
        addChild(hostingVC)
        hostingVC.didMove(toParent: self)
        view.addSubview(hostingVC.view)
        hostingVC.view.translatesAutoresizingMaskIntoConstraints = false
        hostingVC.view.al.pinEdgesToSuperview() // 内製の Auto Layout helper 
        
        ……
    }
}

UIViewController から SwiftUI.View へデータを流し込む

VIPER アーキテクチャで Interactor → Presenter → ViewController と渡ってくるデータを、 SwiftUI で書かれた View 側に伝える際には ObservableObject を使っています。

https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app

本稿のコード例では DataSource と名付けています。View で必要なデータは @Published をつけたプロパティとして、DataSource に定義しておきます。

struct KaimonoCartView: View {
    class DataSource: ObservableObject, ReactiveCompatible {
        @Published var isOrderProcessing: Bool = false
        @Published var isCartProductsLoading: Bool = true
        @Published var cartProducts: [CartProduct] = []
        ……
    }
   
    weak var delegate: KaimonoCartViewDelegate?
    @ObservedObject var dataSource: DataSource
    ……

    var body: some View { 
       ……
    }
}

その上で、DataSource は親の ViewController で所有*7して、View からは @ObservedObject property wrapper でそれを監視させます。

クックパッド iOS アプリでは VIPER のデータフローに RxSwift を利用しているため、ReactiveCompatible プロトコルにも適合させて以下のように繋ぎました。

// KaimonoCartViewController.swift

    presenter.cartProducts
        .drive(dataSource.rx.cartProducts)
        .disposed(by: disposeBag)

    presenter.isCartProductsLoading
        .drive(dataSource.rx.isCartProductsLoading)
        .disposed(by: disposeBag)

    presenter.isOrderProcessing
        .drive(dataSource.rx.isOrderProcessing)
        .disposed(by: disposeBag)

このように ViewController の DataSource へ最新の値を流し込めば、あとは値の変化に応じて自動的に View が再描画されます。

SwiftUI.View から UIViewController へイベントを伝える

View で起きたイベントを ViewController に伝える際には Delegate を使いました。

基本的には UIKit などの命名規則と合わせて、Delegate の呼び出し元の名前に動詞を続ける命名にしています。ただし、第一引数で self を渡すのはやめています*8

// KaimonoCartView.swift

protocol KaimonoCartViewDelegate: AnyObject {
    func kaimonoCartViewDidTapProductTile(productID: Product.ID)
    func kaimonoCartViewDidTapTermsLawButton()
    func kaimonoCartViewDidTapOrderButton()
    ……
}
// KaimonoCartView.swift

    private var footer: some View { 
        HStack(alignment: .center, spacing: 0) {
            ……
            Button(action: {
                delegate?.kaimonoCartViewDidTapOrderButton()
            }, label: {
                Text("注文する")
                ……
            })
        }
    }
// KaimonoCartViewController.swift

    func kaimonoCartViewDidTapOrderButton(){
        // 注文処理のトリガーに必要な値を流す
    }

SwiftUI で実際どうだったか

よかった点:開発効率の向上

まず、UI の組み立ては期待通り快適で素早くできました。その様子は Apple 公式のチュートリアルでもおわかりいただけると思うので割愛します。ここでは実際に UI を作り込んでいく中で良かったと感じた点をご紹介します。

1. 複雑・多様な状態のある画面実装が楽

買い物機能では、サービスの特性上さまざまな要因で表示内容を変える必要があります。 (例:受け取り場所の設定状況、注文締切、受け取りのタイミングなど)

かいものタブ トップ画面のパターン例(まだまだ沢山ある)

従来なら UITableView や UICollectionView を使った実装を考えるところですが、セルやレイアウトの定義、更新タイミングなど考慮すべきこともコード量も多くなります。複雑度が増すほど不具合も生んでしまいやすく、この状況では画面構成を色々と変えてみたくても実装者のフットワークは重くなります。

これに対して、SwiftUI では表示条件をそのままシンプルに書けば良く、データが更新された時の再描画も任せておけます。

具体例1:かいものタブ トップ画面

例えば以下の要件があるとします。

  • 過去に1件以上注文があるなら「最近の買い物を見る」導線を表示したい
    • 配送済み かつ 未受け取りの品があるなら それを「受け取り可能なご注文があります」に変えたい
  • 過去に1件以上注文があるが 配送時のプッシュ通知がオフ なら、通知設定導線を表示したい

View は次のように表現できます。

// KaimonoTopView.swift

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                if dataSource.isLoadingDeliveries {
                    ActivityIndicator()
                } else {
                    if dataSource.hasLeastOneOrder {
                        if dataSource.hasAcceptableDeliveries {
                            acceptableDeliveriesRow // 「受け取り可能なご注文があります」
                        } else {
                            normalDeliveriesRow // 「最近の買い物を見る」
                        }

                        if dataSource.isPushNotificationDenied {
                            pushNotificationDeniedRow // 通知設定導線
                        }
                    }
                }
                ……
            }
        }
    }

実際にはこれ以外にも様々な要件があり、かなり複雑な画面です。しかし、SwiftUI なら素朴に条件を並べるだけで構成できます。コードを見て仕様把握しやすく、改変したいときも素直に変えるだけで済むので保守性が高いと感じました。

具体例2:カート画面

SwiftUI の宣言的な表現の良さは、以下の場面でも実感できました。

  • 注文ボタンが押されたら、注文処理が完了するまではボタンを無効化しておきたい
    • 変更操作や画面遷移をしないように「品数の変更」や「よくある質問」などのボタンも一通り無効化しておきたい
  • もし注文処理が通信エラー等で中断されたら、再度押せるように有効化したい

この要件を .disabled(_:) modifier 1行で対応できるのはとても心地よいと感じます。

https://developer.apple.com/documentation/swiftui/list/disabled(_:)

// KaimonoCartView.swift
// 注文処理中だけ、ボタンを無効化したい

    var body: some View {
        ZStack {
            VStack(spacing: 0) {
                if dataSource.isCartLoading {
                    ActivityIndicator()
                } else {
                    ScrollView {
                        paymentSettingRow
                        deliveryInformationRow
                        deliveryTutorialsRow
                        notesRow
                        faqRow
                    }
                    Divider()
                    footer
                }
            }
            .disabled(dataSource.isOrderProcessing)
            // ↑この1行で、この VStack 内の Button などはすべて良い感じに disabled になる
           
            if dataSource.isOrderProcessing {
                ActivityIndicatorToast()
            }
        }
    }

2. UI コンポーネントの取り回しが楽

同じ「商品タイル」を使う画面例

1つの UI コンポーネントを複数画面で使い回すことはよくあると思います。 UIKit でも使い回し自体は可能でしたが、SwiftUI ではより扱いやすいと感じました。

2.1 柔軟に移植や組み合わせができる

例えば買い物機能では、商品タイル ProductTile という UI コンポーネントを多用しています。

struct ProductTile: View {
    var product: Product
    var didTapImage: (_ productID: Int64) -> Void
    var didTapAddToCartButton: (_ productID: Int64) -> Void

    var body: some View {
       ……
    }
}

商品タイルのパターン例

商品の状態によって「NEW」「数量限定」などのラベルが表示されます。もし選択中の受け取り日にこの商品の配送がなければ「直近でお届けできる日を確認」、在庫がなければ「売り切れ」などのオーバーレイも表示されます。

この ProductTile を機能させるには、少なくとも以下の3つを与える必要があります。

  • 商品データ product
  • 商品サムネイル画像をタップされたときの挙動 didTapImage
  • カート追加ボタンをタップされたときの挙動 didTapAddToCartButton

これは親 View から渡します。

UI コンポーネントの依存の概略図(例:店舗詳細画面)

上図は、商品グリッド表示の ProductsGrid を埋め込んだ店舗詳細画面 KaimonoShopDetailView の例です。ProductsGrid を通して ProductTile はグリッド状に表示されます。

struct ProductsGrid: View {
    var products: [Product]
    var didTapImage: (_ productID: Int64) -> Void
    var didTapAddToCartButton: (_ productID: Int64) -> Void

    ………

    var body: some View {
        ForEach(0 ..< rows.count, id: \.self) { index in
            HStack(alignment: .top, spacing: gridSpacing) {
                ForEach(rows[index]) { product in
                    // ProductTile に必要なものは initializer の引数に示されている。
                    // 使う側はそれを用意して渡せば完了。
                    ProductTile(
                        product: product,
                        didTapImage: didTapImage,
                        didTapAddToCartButton: didTapAddToCartButton
                    )
                }
            }
        }
    }
}

前述の説明とも関連しますが、買い物機能の SwiftUI 実装では 親 View から 子 View へ必要なデータを与えて、子 View で起きたイベントの処理は親に委譲される構造にしました。

つまり、依存は親から注入されるので UI コンポーネント自体はどれも無垢です。その画面特有の都合などは最終的に委譲された先にたどり着く ViewController の実装が担います。そのため、UI コンポーネント自体はどの画面にもほぼコピー&ペーストですぐ移植できますし、互いに組み合わせたり入れ込むこともたやすくできます。

異なる UI コンポーネントに置き換えたいときも、同じものに依存しているなら変更箇所はごくわずかで済みます。

UIKit でも扱いやすい UI コンポーネントを作ることは可能でしたが、SwiftUI.View の struct ならより手軽に用意できる印象があります。

2.2 後から細分化もしやすい

必要なタイミングで「改変して独立」しやすくなっているとも感じました。

UI コンポーネントは細かすぎても荒すぎても不便ですし、開発状況の変化によって望ましい形は変わっていきます。最初は複数画面で同一でも良かった UI コンポーネントも、別々の道を歩みはじめたくなるケースはよくあります。

そのように分離して独立させたいとき、UIKit では Interface Builder (.xib, .storyboard) を使う場合でもすべてコードで書く場合でも Auto Layout の制約*9などに気を配る必要がありました。また、もし古い IBOutlet の紐付きを見落とせば、アプリのクラッシュの原因*10にもなってしまいます。

SwiftUI では前述の依存の構造とも相まってそのようなしがらみがなく、一部だけ抽出して独立させるのもかなり簡単だと思えました。

最初から粒度を気にせずに UI を作り始められて、後で必要になったときに結合・分離に柔軟に対応できるのは魅力的です。

3. スタイル調整も楽

スタイルを調整しやすいのも良い点です。よく使うシャドウや角丸に加えて、マージン調整、文字の装飾なども UIKit に比べて簡潔で扱いやすくなっています。細部までこだわった表現も簡単にできて、デザイナーと一緒にベストな実装を模索できました。

デザイナーも Pull Request を

デザイナー自身で細部を調整した Pull Request

同じチームのデザイナー @sn_taiga さんが自らスタイル調整の Pull Request を出してくれることが何度かありました。

UI を微調整したいとき、エンジニアとデザイナーの間で指示・実装・修正確認のやりとりが何度も発生することもあると思います。もしデザイナーが直接修正できるなら、オーバーヘッドを減らして時間を短縮できます。

従来の UIKit に比べて分かりやすく、iOS エンジニアでなくても簡単に修正できる部分が増えていくのは大きな進歩だと感じます。

困った点:余計な苦労もある

メリットもありましたが、予想通り SwiftUI を使うことで苦労した点もあります。

1. 不具合

iOS 13.0〜13.4 あたりでは「それはないでしょ……」という不具合もありました。一つ一つ対応策はあって地道に対処していくことになるのですが、それだけでもう一記事かけそうなので、ここでは一例だけ紹介します。

例:ScrollView 内の Button が、スクロールのためのタップで誤動作してしまう

iOS 13 の初期の頃に実機で起きる不具合で、動作確認中にショックを受けました。これは少なくとも iOS 13.5.1 以降*11では修正済みのようです。買い物機能ではワークアラウンドとして .onTapGesture を代わりに使っており、時期を見て Button に戻していく予定です。iOS バージョンによって分岐する Button 用コンポーネントを用意する案も検討しています。

    ScrollView {
        VStack(alignment: .leading) {
            ……
            CartSectionHeader(title: "受け取り情報")
            PickupNameRow(pickupName: dataSource.pickupName)
                .onTapGesture(perform: didTapPickupNameRow)
                // Button はスクロール時のタップで誤動作することがあるので、代わりに .onTapGesture を使う
            Divider()            
            ……
        }
    }

2. 機能不足

iOS 14 からは必要なものが大分揃った印象がありますが、iOS 13 の SwiftUI では「あってほしい……」と思う機能が足りません。想定していた通り UIKit で代用するか、工夫したコンポーネントを用意するなどの対応が必要になります。

例1:ScrollView の contentOffset の get / set

ボタンをタップしたら指定位置まで自動でスクロールしたい、といった要件はよくあります。しかし、iOS 13 の SwiftUI の ScrollView ではスクロール位置の設定が UIKit のように簡単にはできません。iOS 14 からは ScrollViewReaderScrollViewProxy が登場してできるようになりました。

https://developer.apple.com/documentation/swiftui/scrollviewproxy

例2:読みやすい幅対応

クックパッド iOS アプリは iPad にも対応しており、読みやすい幅を考慮した UI にしています。

techlife.cookpad.com

Auto Layout では readableContentGuide のような仕組みが用意されていましたが、iOS 13 の SwiftUI では公式に用意された仕組みを見つけることができませんでした。

そのため GeometryReader と 与えられた画面幅に対して読みやすい幅を返す ApproximateReadableContent というヘルパー(社内製)を組み合わせた ReadableScrollView を用意して対応しました。

struct ReadableScrollView<Content: View>: View {
    var content: Content

    var body: some View {
        ScrollView {
             HStack(alignment: .top, spacing: 0) {
                  Spacer(minLength: 0)
                  VStack(alignment: .leading, spacing: 0) {
                      content
                  }
                  .frame(maxWidth: ApproximateReadableContent.maximumWidth)                  
                  Spacer(minLength: 0)                  
            }
        }
    }
}

他には

@Environment(\.horizontalSizeClass) var sizeClass

をもとに適切な padding を設定するという方法もあるようです。

例3:遅延読み込み

VStack 内に並べる項目数が非常に多くなると、画面描画がカクカクしはじめるなどパフォーマンス上の問題が出てきます。

見えない部分まですべて描画するのではなく、スクロールに合わせて逐次構築していく「遅延読み込み」を実現したいところですが、iOS 13 の SwiftUI ではまだ便利な仕組みが用意されていません。項目数や構成を減らして軽くするか、従来の UICollectionView などに置き換えるかといった選択肢になります。

iOS 14 からは LazyVStack / LazyHStack のように欲しかったものが揃ってきました。詳しくは WWDC 2020 の Stacks, Grids, and Outlines in SwiftUI をご参照ください。

developer.apple.com

まとめ

SwiftUI の導入によって、総合的には開発体験と効率は向上できたと思っています。一度慣れれば新規の UI 実装も素早くでき、改変も楽にできます。何より楽しく開発できるという点で、SwiftUI を採用して良かったと感じます。

iOS 13 の初期バージョンの SwiftUI にはまだ困る面もあります。ただそれは今後最新の iOS が普及するにつれて解決する問題だと思っています。現状では QA 体制・自動テストの工夫で問題に気づけるようにし、事業的な優先度と実装の難易度を把握して、適宜 UIKit を使うなどの判断ができる体制で付き合っていく必要はあります。

チームで「改善していける」良い雰囲気づくり

開発体験の向上については、アプリ実装の素早さがチームの雰囲気づくりにも貢献できた点があると感じています。

デザイナーと話しながら、色々なパターンを素早く実際に作って、本番データでアプリの挙動をすぐに試してみることも可能でした。ミーティングで出たアイデアをすぐに具体化してみて、UI 面の課題やコンテンツ面・運用上の問題に気づいて軌道修正する、そのように勢いよく改善が進んでいくと、開発のモチベーションも上がると思っています。

開発チームの Slack の盛り上がりの様子

このように楽しく素早い開発ができる環境を整え、チームで次々と改善していける良い雰囲気づくりができた点でも一定の成果はあったと感じています。

一方で、現時点のユーザー体験はまだまだ理想形にはほど遠い状態です。 2021年はこの作り変えやすい土台を活かして、「レシピ」と「買い物」が融合した圧倒的に良い体験を探っていきたいと考えています。

クックパッドでは仲間を募集しています!

今回は買い物機能の開発にあたっての技術選定や SwiftUI の活用事例についてご紹介しました。

買い物機能の取り組みにご興味を持ってくださった方は、プロダクトマネージャー @naganyo さんの1年の振り返りの記事もぜひご一読ください。 note.com

クックパッドでは技術を活用してサービスや事業を推進していきたい方を大募集中です!

iOS・Android・Web フロントエンド、サーバーサイド(Ruby on Rails, Java 等)、検索技術、ログ分析、マーケティングなどなど様々な領域で取り組みたい課題が沢山あります。 カジュアル面談や学生インターンシップなども随時実施していますので、ぜひお気軽にご連絡ください!

cookpad.careers

*1:近隣地域の生産者や市場直送の新鮮でおいしい食材を、1品から送料無料で購入できる。https://info.cookpad.com/pr/news/press_2020_1015

*2:最短で注文当日に受け取り可能。https://www.youtube.com/watch?v=FIhAFjVmS10

*3:より正確には「Apple プラットフォーム向けのアプリ」で、iOS や macOS に限らず tvOS や watchOS のアプリも作れる。https://developer.apple.com/xcode/swiftui/

*4:最初のコミットは2012年の8月、2020年12月末時点では6万1,000以上のコミットを経て、24万1,800行のコードベースがある。

*5:クックパッドのエンジニアが語る、巨大で歴史あるアプリにおける破壊と創造 - ログミーTech https://logmi.jp/tech/articles/321186

*6:検証当時、NavigationView は動作が不安定な部分があったため。List, Form はカスタマイズ性に乏しく、TextField も日本語変換時の動作が怪しかったため。

*7:@ObservedObject は iOS 13.3 未満あたりで値を弱参照していて、ふとした拍子に消える可能性があったので ViewController 側に持たせるようにしている。

*8:UIKit では UI 操作などのために外部に提供するインターフェイスとなるが、SwiftUI では ObservableObject のように別の形が提供されているので基本的には不要なはず。むしろ SwiftUI のスコープ外から意図しない操作を可能にしてクラッシュさせる恐れもある。ここではそれを回避したいため。

*9:既存実装では Auto Layout の制約がからまっていて読み解きが大変だったり、結局一度消してすべて付け直したりすることもよくある。UIStackView によって改善されたものの、後から一部を抜き出すときはやや煩雑な印象がある。

*10:コードと xib の両方から消したつもりで、xib 側に一部残っていると実行時エラーでクラッシュする。

*11:いつの間にか直っていた。実機のみで起きる不具合で、アップデート後は iOS 13.0 〜13.4 の実機が手に入りづらく、修正された正確なバージョンはわからない。