Mercari Engineering Blog

We're the software engineers behind Mercari. Check out our blog to see the tech that powers our marketplace.

Flutter製MTC2018アプリをSwiftUIでリライトした話

こんにちは。メルカリアドベントカレンダー 2019 7日目担当は、メルカリ Engineering Office @jollyjoester とゆかいな仲間たちがお届けします。

2019年6月に開催されたWWDC2019にて、AppleのプラットフォームのUIを構築するための新しい手法 SwiftUIが発表されました。この記事はSwiftUIの学習のために、以前開発したFlutter製のアプリをSwiftUIで書き換え、OSSとして公開したお話です。

f:id:jollyjoester:20191205002900p:plain
MTC2018 App SwiftUI

きっかけ(@jollyjoester)

昨年開催したMercari Tech Conf 2018(以下MTC2018)にて、私たちはメルカリグループ内で有志を募ってFlutter製のカンファレンス専用アプリ*1を開発しました。 そのときのメンバーとはチームビルディング(通称チービル*2)で継続的に やっていき (やっていく気持ち?)を温めていたのですが、WWDC2019直後に実施したチービルランチでは当然SwiftUIの話で盛り上がり、突発的に「SwiftUIでMTC2018アプリをリライトするぞ」プロジェクトが発足しました。

f:id:jollyjoester:20191205155747p:plain
チービルランチ直後の勢い余ったSlackポスト

きっかけは「SwiftUI出たけどProductで使うのはまだ先だね〜でもキャッチアップしたいね。アレ?MTC2018アプリ使えるんじゃね?」という流れだったと記憶しています。 アプリとしての規模もほどほどで、仕様やデザインなどはすでに決まっているので実装の部分だけ置き換えれば良いという、うってつけな対象でした。

その当時のやっていきあふれる社内Wikiの一部を下記に転記します。

# mtc-2018-appをSwiftUIで書き換えるプロジェクト

## これは何か?

意識高いチービルにより生まれた有志プロジェクト。目指せ技術キャッチアップ&アウトプット

## 目的

近年、技術の進歩のスピードが上がっており、日々新しい技術が発表されている。
SwiftUIもその一つ。ただ、Productですぐ利用するにはハードルが高い。
技術をキャッチアップするにはモノを作るのが一番。
ということでSwiftUIを作ったアプリを作る!

## なぜmtc-2018-appの書き換えか?

- スクラッチで開発しやすい規模
- 仕様、デザインがすでに決まっているのでエンジニアのみで開発できる
- すべて公開済みの情報のみなのでPR上のリスクがない
- 外部エンジニアがすぐに触って試せる成果物になるのでアウトプットを多くのエンジニアに触ってもらいやすい

こうしてMTCアプリをSwiftUIで書き換えるプロジェクトが始まりました。

FlutterからSwiftUIに作り変えた感想 (@masamichi)

こんにちは。MTCアプリチームの @masamichi です。去年のMTC2018のアプリはFlutterで開発しましたが、今回SwiftUIで書き換えました。

WWDCで突然SwiftUIが発表され何か作りたいなーと思っていたところ、上記のようにMTCアプリを書き換えようという話になりました。MTCアプリは新しいフレームワークを試すのにちょうどいい規模と仕様で毎回楽しく開発ができます。Flutter, SwiftUIそれぞれに特徴があるので、完全に同じデザイン、仕様で書き換えた訳ではありませんが、主な機能は移植できています。

オリジナルのアプリはFlutterで作っていたので、WidgetをだいたいそのままSwiftUIのViewに置き換えていきました。 例えば展示ブース一覧画面のそれぞれのカードはFlutterでは以下のようなWidgetを作っていました。

Card(
  color: Colors.white,
  child: FlatButton(
      onPressed: () {
        Navigator.push(
            context,
            MaterialPageRoute(
                settings: RouteSettings(name: "/content_detail"),
                builder: (context) {
                  return ContentDetailPage(
                    exhibition: exhibition,
                  );
                }));
      },
      padding: EdgeInsets.all(0.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          image != null ? image : Container(),
          Container(
              padding: const EdgeInsets.only(
                  top: 16.0, left: 24.0, right: 24.0),
              child: Container(
                  child: Text(
                      exhibition.localizedTitle(
                          getCurrentLanguageCode(context)),
                      style: TextStyle(
                          fontWeight: FontWeight.bold,
                          fontSize: 18.0,
                          color: Colors.black)))),
          Container(
              padding: const EdgeInsets.only(left: 24.0, right: 24.0),
              child: Container(
                  margin: const EdgeInsets.only(top: 8.0, bottom: 24.0),
                  child: Text(
                      exhibition.localizedDescription(
                          getCurrentLanguageCode(context)),
                      maxLines: 3,
                      overflow: TextOverflow.ellipsis,
                      style: TextStyle(color: Colors.black))))
        ],
      )));
}

SwiftUIではこんな感じです。

struct ExhibitionRow: View {

    @State var exhibition: Exhibition

    var body: some View {
        NavigationLink(destination: ExhibitionDetailView(viewModel: ExhibitionDetailViewModel(exhibition: exhibition))) {
            VStack(alignment: .leading, spacing: nil) {
                if !exhibition.exhibitionImage.isEmpty {
                    Image(exhibition.exhibitionImage.components(separatedBy: ".")[0])
                        .renderingMode(.original)
                        .resizable()
                    .scaledToFit()
                }

                Text(exhibition.localizedTitle)
                    .font(.system(size: 18, weight: .bold))
                    .padding(.bottom, 8)

                Text(exhibition.localizedDescription)
                    .lineLimit(3)
                    .font(.system(size: 14))

            }
            .foregroundColor(.primary)
        }
        .padding(EdgeInsets(top: 12, leading: 24, bottom: 12, trailing: 24))
    }
}

f:id:masamichiueta:20191205150219p:plainf:id:masamichiueta:20191205150226p:plain
[左] Flutter [右] SwiftUI

アーキテクチャはMVVMを採用しています。せっかくのSwiftUIなので、そのうちFlux, Redux版も作りたいですね。@kitasuke さんが公開しているSwiftUIのサンプルを参考にしています。

github.com

開発は7月から少しずつ始めていました。開発開始時のXcode 11.0 Beta3だったと思います。そこからXcodeの新しいBetaバージョンがリリースされるたびに更新していきました。この頃SwiftUIを使われていた方はご存知だと思いますが、Beta版なので更新によってはBreaking Changeが結構入っていて追従しなければなりませんでした。例えばナビゲーションに使用していたAPIがdeprecatedになってしまい他の方法を考えたりしました。

特定のコンポーネントはSwiftUIにはまだ用意されておらず、ActivityIndicator, WebView, PageViewなどはUIKitを使わなければいけませんでした。PageViewはSwiftUIの公式チュートリアル Interfacing with UIKitで紹介されている方法を使用しているのですが、このPageViewの中にNavigationLinkを入れると現在の最新バージョン Xcode11.2.1, iOS13.2.2 でポップ時にクラッシュしてしまうという問題があります。

また、タイムテーブル一覧画面のように、1つのRowからセッションの詳細画面またはスピーカーの詳細画面と、複数の画面に遷移する方法を実現するのにとても苦労しました。私がとった方法は、ListではなくScrollViewを使って手動でリストを作成するという方法です。ScrollViewであれば1つのRowの中に複数のNavigationLinkを置いても正常に遷移できるのですが、Listを使うと画面遷移がおかしくなってしまいました。

  • Listを使った場合
struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                ForEach(0..<10) { _ in
                    VStack {
                        NavigationLink(destination: Text("NavigationLink1")) {
                            Text("NavigationLink1")
                        }
                        NavigationLink(destination: Text("NavigationLink2")) {
                            Text("NavigationLink2")
                        }
                    }
                }
            }
        }
    }
}

f:id:masamichiueta:20191127174124g:plain
Listと複数のNavigationLinkを使った場合

  • ScrollViewを使った場合
struct ContentView: View {
    var body: some View {
        NavigationView {
            ScrollView {
                ForEach(0..<10) { _ in
                    VStack {
                        NavigationLink(destination: Text("NavigationLink1")) {
                            Text("NavigationLink1")
                        }
                        NavigationLink(destination: Text("NavigationLink2")) {
                            Text("NavigationLink2")
                        }
                        Divider()
                    }
                }
            }
        }
    }
}

f:id:masamichiueta:20191127174301g:plain
ScrollViewと複数のNavigationLinkを使った場合

せっかくSwiftUIで作るのでDarkModeにも対応しようということで、DarkModeにも対応しています!とはいえ、テキストや背景色はシステム標準のものを使用しておけば特に対応する必要はありません。特別に対応が必要なのは、画像やカスタムカラーの部分です。画像はそれぞれLightMode、DarkMode用の画像を用意しました。またカスタムカラーはxcassetsにColorSetを追加することでLightMode, DarkModeのそれぞれの色を設定することができます。

f:id:masamichiueta:20191127174343p:plain
MTCアプリで使っているカスタムカラー

各種アイコンは、iOS13以降で使用できるSF Symbolsを使っています。かなりたくさんの種類が用意されていて、よほどカスタマイズする必要がない限り、SF Symbolsで間に合う気がします。

今回SwiftUIで開発しましたが、ReactやFlutterのようなComponent指向、宣言型シンタックスがついに公式でサポートされたのがやっぱり嬉しいですね!ノウハウが必要な部分もありましたが、全体を通してやっぱり作りやすかったです。SwiftUIはまだバージョン1で、足りないコンポーネントもありますが、今後はFlutterのように、たくさんのコンポーネントをサポートしていって欲しいです。

WWDC初参加の熱が冷めぬうちに (@kagemiku)

こんにちは。MTCアプリチームに勝手に参加している @kagemikuです。今年のWWDCはSwiftUIやCombineの発表と、Framework面でかなり盛り上がる内容でしたね。今年は自分もチケットが当たり、弊社のiOSエンジニアの方々と共に現地参加してきました。

Mercari members at Worldwide Developers Conference 2019 ! #MercariDays | mercan

WWDCから帰国後もその熱は冷めず、試行錯誤しながらSwiftUIと格闘する日々を送っていました。 そんなある日、techconfのアプリに関するチャンネルを社内Slack内でたまたま見つけ、SwiftUIでやっていきしたいよねと会話している様子を目にしました。

f:id:kagemiku:20191202195558p:plain
slack post

これに参加しない手は無いなと思い早速参加表明し、前述のチービルランチ(WWDC19直後に実施されたもの)にも参加後、晴れてわいわいすることになりました。 @masamichiさんも書かれていますが、その頃のXcodeはBetaもBetaで、新しいBetaが公開されるたびにビルドが通らなくなって苦労した思い出があります。その度に@masamichiさんがシュッと修正しておりました。

プロジェクト開始当初、自分がちょうどSign In with Appleについて調査していたこともあり、まずはSign In with Appleの機能を実装しました。元々のMTC2018アプリにはそのような機能はありませんが、とりあえず勢いです。 Sign In with AppleのButtonはSwiftUIで直接記述できないため、UIViewControllerRepresentableに適合したViewControllerを作成し、それをSwiftUI内で表示するという流れで実装しました。

struct AppleIDButtonViewController: UIViewControllerRepresentable {
    @ObservedObject var userState: UserState
    let completion: ((Bool) -> Void)?
    ........................

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIViewController(context: Context) -> UIViewController {
        let authorizationButton = ASAuthorizationAppleIDButton(authorizationButtonType: .default, authorizationButtonStyle: .black)
        authorizationButton.addTarget(context.coordinator, action: #selector(Coordinator.handleAuthorizationAppleIDButtonPress), for: .touchUpInside)

        let vc = UIViewController()
        vc.view.addSubview(authorizationButton)
        .........................

        return vc
    }

    ........................

    class Coordinator: NSObject, ASAuthorizationControllerDelegate {
        var parent: AppleIDButtonViewController
        ............................
    }
}
VStack {
    AppleIDButtonViewController(userState: userState, completion: { ........ })
        .frame(height: 50)
        .padding()
}

f:id:kagemiku:20191202210803j:plain
Sign In with Apple

当初は各タブにNavigationBarを生やし、NavigationBarItemの1 Buttonとして実装していました。ただ、Flutterで実装されているオリジナルのMTC2018アプリのような、タブをまたいだ共通的なNavigationBarの実装やモーダルの取り扱いに難があり、Xcode Betaのバージョンアップの度に壊れることも。。 現在OSSとして公開されているバージョンでは、当初の仕様を断念し、Accountタブの中の1 Buttonとして生きています。

その後も時間を見つけては機能を少しずつ追加し、@masamichiさんの爆速コントリビュートの甲斐もあって、なんとか公開できる形にまで仕上がりました👏 (@masamichiさん本当にありがとうございました。) サードパーティのライブラリを一切使わず、純粋なSwiftUIとCombineだけで実装しましたが、どちらも公式のFrameworkということもありその親和性は非常に良かったです。 AndroidのJetpack Composeも発表され、Declarative UIがこれからより盛り上がっていくのかなと思うと嬉しいですね 🥰

まとめ

いろいろあって(jollyjoesterがサボっていて)SwiftUIの正式リリース直後の公開とはなりませんでしたが、成果物を下記のリポジトリに公開することができました。 やっていき がある方はどなたでもContributeしていただけます。フィードバックも大歓迎です!

ぜひ一緒にSwiftUIを学んでいきましょう!

github.com

最後に一緒にプロジェクトを推進していただいた@masamichi さん、@kagemiku さん、@natpenguinさん、@kuuさんありがとうございました!

明日の執筆担当は、メルカリ Web Platformチームの @mkazutaka さんです。それでは引き続きお楽しみください。

*1:「お待たせしました、Mercari Tech Conf 2018 アプリの裏側をお見せします!#mtc18」

*2:部署間を超えて社内コミュニケーションを活性化させるための会社の制度