nana開発者ブログ

音楽SNSアプリ"nana"開発チームのブログです。

Xcode Previewsをまとめて実機で触れるUIプロトタイプアプリにする

@hiragramです。nanaのプロダクトマネージャーとiOSアプリ開発を担当しています。

nanaは現在フルリニューアルに向けた作り直しに取り組んでいます。2022年9月現在、デザインや仕様を練ることと、手触りを確かめるためのプロトタイプ実装とを、グルグル繰り返しているところです。そのグルグルをより速くするために作った仕組みを紹介します。

UI確認用のプロトタイプアプリ

nanaのiOSアプリ開発において、私たちはコードを書かずとも簡単にモックを作れるようなよくあるプロトタイピングツールを使わず、ネイティブアプリとしてプロトタイプを実装しています。nanaのプロトタイプには単なるデザインの事前確認だけではなく、仕様に対する設計の事前確認、シンプルな良い設計のための仕様の事前確認といった目的があり、それらは既存のプロトタイピングツールでは実現できないためです。

実際にプロトタイプを実装しながら、「ここをこういう仕様に変えたら、もっとシンプルできれいな設計ができるのに」という点を見つけて、POやデザイナーと話してそのように仕様を変更した実例がたくさんあります。アプリとしての理想だけではなく、ソフトウェア製品としての品質もきちんと重要視してくれる良い環境です。😎

プロトタイプアプリの生い立ち

現時点では、nanaのフルリニューアル版アプリは画面を全てSwiftUIで実装するつもりです。そして、画面やパーツを実装する際にはXcode Previewsを活用し、ダークモードやローカライズへの対応もきちんと考慮しながら開発しています。

ある時ふと、開発の生産性に寄与するこのプレビューをそのまま実機で動かすことができたら便利なのでは?漏れがちなダークモードや他言語での動作確認も手軽にできるのでは?と思い、仕組みを考え始めました。

どうやってやっているか

nanaのプロトタイプアプリは、以下のようなステップで実現されています。

  1. UI開発時にXcode Previewsをちゃんと整備して、動作確認に使える品質にする。
  2. メインのアプリとは別にプロトタイプアプリのターゲットを用意する。
  3. Sourceryを使って、Xcode Previewsを収集しプロトタイプアプリから利用可能なViewとして出力する
  4. Xcode CloudとTestFlightを使って、プロトタイプアプリをチームメンバーに配布する

今回はこの中の3について説明します(nanaではXcodeGenを導入しており、2も超簡単です😆)。

SourceryでXcode Previewsを収集しプロトタイプ用のViewとして出力する

Sourceryとは、SwiftSyntaxを用いてSwiftで書かれたコードを解析し、Stencilテンプレートを使ってコードを出力する、所謂メタプログラミングを実現するコード生成ツールです。

以下のStencilテンプレートを使って、Sourceryで読み込んだファイル内にあるXcode Previewsをプロトタイプアプリから呼び出すViewとして出力します。

import SwiftUI
@testable import NanaUI // 1️⃣

struct AllPreviews {
    private static var entries: [CatalogEntry] = { 
        [
            {% for type in types.based.PreviewProvider where type.based.CatalogIgnored == nil %} // 2️⃣
            {% set pathGroupComponents type.path|split:"UIComponents/NanaUI/Documented/" %}
            {% set group pathGroupComponents.last %}
            CatalogEntry(
                content: AnyView({{ type.name }}.previews),
                navigationTitle: "{{ type.name }}",
                filePath: "{{ group }}",
                label: {
                    PreviewListItem({{ type.name }}.self)
                }
            ),
            {% endfor %}
        ]
            .sorted(by: { $0.filePath < $1.filePath })
    }()

    @ViewBuilder
    static var items: some View {
        ForEach(entries) { entry in
            Group {
                NavigationLink(
                    destination: {
                        entry.content
                            .navigationTitle(entry.navigationTitle)
                            .toolbar {
                                ToolbarItem(placement: .navigationBarTrailing) {
                                    CatalogConfigMenu()
                                }
                            }
                    },
                    label: entry.label
                )
                Divider()
            }
        }
    }
}

1️⃣ @testable import NanaUI について

テストコードで使われることが多い @testable import は、internalな型やメソッドなどを外から見えるようにする効果があり、実はテストコード以外でも使えます。

新しくSwiftUI Viewのファイルを作成した時に生成されるプレビューは、internalな型として定義されるため、明示的にpublicにするか、@testable importを使うかのどちらかになります。日頃の開発に煩わしさを持ち込みたくなかったため、今回は後者を選択しました。

ちなみに、@testable importを使ったコードはRelease configurationでビルドが通らなくなりますが、社内向けのプロトタイプですので全てDebug configurationでビルドして配布することでよしとしています(TestFlightへのアップロードとテスターへの配信も問題なくできています🤪)。

2️⃣ for type in types.based.PreviewProvider where type.based.CatalogIgnored == nil について

現時点での運用では、UIフレームワークの中の Documented というディレクトリの中にファイルを置いたViewのみ、Xcode Previewsを収集してプロトタイプアプリに収録する、ということになっています。しかしながら、収録したいViewの中にも、例えばデバッグ用途で作ったような雑なプレビューが含まれていることもあります。そういったものをプロトタイプアプリから除外するために、除外したいプレビューの型に CatalogIgnored というプロトコルをつけることにしました(中身は空のプロトコルです)。つまり、「PreviewProviderプロトコルがついていて、CatalogIgnoredがついていない型」のみをコード生成の対象にしているのです。

生成されたView群をアプリに組み込む

上記のStencilテンプレートによって生成されたコードは以下のようになります。entriesの部分が、実際の各プレビューの型を扱っていることがおわかりでしょうか。

// Generated using Sourcery 1.8.2 — https://github.com/krzysztofzablocki/Sourcery
// DO NOT EDIT
import SwiftUI
@testable import NanaUI

struct AllPreviews {
    private static var entries: [CatalogEntry] = { 
        [
            // 省略...
            
            CatalogEntry(
                content: AnyView(MicrophoneLevelControl_Previews.previews),
                navigationTitle: "MicrophoneLevelControl_Previews",
                filePath: "MicrophoneLevelControl.swift",
                label: {
                    PreviewListItem(MicrophoneLevelControl_Previews.self)
                }
            ),
            CatalogEntry(
                content: AnyView(RecordingProto_Previews.previews),
                navigationTitle: "RecordingProto_Previews",
                filePath: "RecordingProto.swift",
                label: {
                    PreviewListItem(RecordingProto_Previews.self)
                }
            ),
            CatalogEntry(
                content: AnyView(RegionTrimControl_Previews.previews),
                navigationTitle: "RegionTrimControl_Previews",
                filePath: "RegionTrimControl.swift",
                label: {
                    PreviewListItem(RegionTrimControl_Previews.self)
                }
            ),
            
            // 省略...
            
        ]
            .sorted(by: { $0.filePath < $1.filePath })
    }()

    @ViewBuilder
    static var items: some View {
        ForEach(entries) { entry in
            Group {
                NavigationLink(
                    destination: {
                        entry.content
                            .navigationTitle(entry.navigationTitle)
                            .toolbar {
                                ToolbarItem(placement: .navigationBarTrailing) {
                                    CatalogConfigMenu()
                                }
                            }
                    },
                    label: entry.label
                )
                Divider()
            }
        }
    }
}

そして、アプリからは以下のように呼び出すだけです🤗

struct ContentView: View {
    var body: some View {
        NavigationView {
            ScrollView {
                LazyVStack {
                    AllPreviews.items
                }
            }
            .navigationBarTitleDisplayMode(.inline)
            .navigationTitle("nana UI catalog")
        }
    }
}

プロトタイプアプリを起動すると、収録されたプレビューのリストと、実際に触れるプロトタイプができたことがわかります。

あとはXcode Cloud上でアプリをビルドし、そのままTestFlightでチームメンバーに配布すればおしまいです。

現在iOS版の開発は私ひとりのワンマンチームでやっているので、その日の終わりにその日のコードをマージして、30分くらい待つと他のnanaチームのメンバーの手元に勝手に降ってくる(TestFlightの自動アップデートは便利ですね😉)ような運用にしています。

また、画面全体を作らずとも個別のパーツ(例えば上の画像にあるRegionTrimControlのようなもの)単位で手触りを確かめることもできてとても便利です。

やってみて思ったこと

上でも触れましたが、このプロトタイピングの仕組みによって、仕様をよく見つめ、情報設計をし、コードの設計をよく検討し、実際に動く様子を確認しながらコードを書き、時には手触りが悪く設計からやりなおし、というサイクルを高速に回せるようになりました。

新しい機能を実装するとき、仕様側だけどんどん夢が膨らんでいって、開発側の設計や実装難易度があまり考慮されないままプロジェクトが進み、気づいたら耐改修性や拡張性に乏しい複雑なコードになってしまった、という経験をお持ちの方は多いのではないでしょうか。特にnanaの録音編集画面のような、単画面に多くの操作と状態が詰め込まれた機能の開発においては、見た目のみのプロトタイピングではなく、無茶なく実装できるのか、シンプルでよいコードを保つために見直せる仕様はないか、といった視点があると、機能やコードの賞味期限をより長くすることができると信じています。

真のメイントピック

私と一緒にnanaのiOSアプリを作っていってくれる方を募集しています。

私はチームの生産性をイイカンジにする仕組みを考えたり基盤を整備したりするのが好きで、これから入ってきていただく方に平和で楽しい開発環境を提供できます。

また、nanaは単にAPIサーバーから情報を取得して表示するだけ(いわゆる色のついたJSONというやつ)のアプリではなく、クライアント側完結の複雑なオーディオ録音編集機能を持ったアプリです。特有の難しさがありますが、個人的にはこれまでの経験をもって立ち向かうにはとても刺激的で楽しい環境です。音声エンジンの部分は専任のサウンドチームがいるので、音声信号処理技術のバックグラウンドは無くても大丈夫です(私もありません😉)。

私(PM/iOS)、PO、経営陣、他の職種、あらゆる形のカジュアル面談を設定できます。まずはビデオ通話で、会社やフルリニューアルのことを紹介させてください。個人的なつながりがある方は私のTwitter宛にDMを投げても良いです😍

jobs.nana-music.com

meety.net