Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

SwiftUIの勉強にTechRacho Feed Readerを作ってみた

はじめに

WWDC2019で発表以来すでに4年目となりますが、みなさんはSwiftUIを弄ってみたり導入してみたりした経験はありますでしょうか?

私は発表以来様子見をしておりました。というのも発表当初は、当時の最新OSであるiOS13でしか使うことができず、サポートバージョンの範囲の狭さから、数年はプロジェクトで導入は難しいだろうなと決め込んでいました。また、2年ほど前から社内でクロスプラットフォーム開発の波がきており、Flutterでの開発が盛んになる一方、Swiftを使ってネイティブで開発することがあまりなくなってしまいました。結果的にSwiftUIを触るモチベーションがあまり湧きませんでした。

しかし、既にSwiftUIが登場してから3年が経過しており、

  • サポートバージョンもiOS13から次期バージョンを含めて16までと、サポート範囲が広くなり、案件での導入が現実的に可能になったこと
  • SwiftUIの利用率は年々増えてきておりこれからも普及していくことが考えられること
    -- こちらのブログで詳しく語られています

ということで、モバイルアプリ開発を専門としている以上無視できるものではなくなってきました。
そこでお勉強として、SwiftUIとUIKitでアプリを作成し、比較してみるというのがこの記事の目的となります。

ちなみに、全く触れたことがない人は、まずはAppleが公開しているSwiftUIのチュートリアルに触れてみるのが良いと思います。

まず結論から

ちょっと記事が長くなってしまったので、まず結論から入ります。

SwiftUIとUIKitを比較した結果、SwiftUIには様々なメリットがあるが、NavigationControllerの代替となるNavigationが致命的な欠点を抱えていると判断しました。

そのため、現状ではベースをUIKitで作り、画面遷移の仕組みとしてはNavigationControllerを使いつつ、ViewのレイアウトをSwiftUIで作るのが現実的と私は考えました。

作ったもの

今回は、TechRachoの記事ということで、TechRachoのFeedを読み込んで表示するアプリを作成しました。
比較のためUIKit版とSwiftUI版を作成し、なるべく同じような見た目になるように作りました。

ソースについては、GitHubに公開しています。

UIKit SwiftUI

機能

本アプリでは以下の機能を実装しています

  • 更新ボタンを押すことで、Atom形式のFeedを取得し記事一覧を表示する
  • 最近取得した記事はキャッシュし、次回起動時はそれを表示する
  • 該当記事を選択すると記事をWebViewで表示

アプリケーション設計

アプリケーションの設計としては、MVVM+Usecase+Repositoryで作成し、Viewの部分をUIKitとSwiftUIで分けています。

ViewModel及びModel部分に関しては、共通のものを使っています。

それぞれ、ざっくり以下の役割としています。

  • View - UIを表示するためのレイヤー
  • ViewModel - Viewに必要なデータの取得や状態の保持を行う
  • Usecase - いわゆるビジネスロジック
  • Repository - データの取得、保存を行う

アプリケーションの機能が少ないので、正直ここまでかっちりした設計は必要としていないです。が、Swiftをしばらく触れておらず、別の案件などで再利用できるものを作りたいなと言う意図がありました。また、SwiftUIと時同じくして登場したCombineを上記の設計内で組み込んでみたかったので、少しだけきっちり目に設計してみました。
キャッシュ機能などを追加したのも、Usecaseに役割を持たせたかったためです。

本記事では、主にView部分についての解説となりますので、他のレイヤーの実装が見たい方はGitHubに上げたソースを見てください。

SwiftUIとUIKitの比較

本アプリでは、記事一覧画面と記事の詳細画面(WebView)の二つの画面があります。

主に以下の単位でファイルの分類を行いました。

  • 記事一覧
  • 記事ごとのパーツ
  • 詳細画面

それぞれ見ていきましょう。

記事一覧

まず、記事一覧全体の作りについてです。

SwiftUI版

まず、SwiftUI版からです。

コンテナ

SwiftUIではViewのbody内にViewの要素を記載していきます。
Viewの親子関係が、以下のように中括弧で囲うような入れ子の構造となっており、HTMLのように親子関係が分かりやすく記載できます。
SwiftUIでは、このようにViewを含めることができるViewのことをコンテナと呼びます。

NavigationView {
    ScrollView {
        // --- 中略 ---
    }
}

modifier(モディファイア)

特定のViewに対して、.繋ぎで装飾することができます。
フォントサイズや背景色の変更、パディングを設けるなどが可能です。
これをmodifier(モディファイア)と言います。

ArticleRow(viewModel: ArticleRowViewModel(article: article), width: geometry.size.width - padding)
    .background(Color(.systemGray6))
    .cornerRadius(10)

NavigationLink

※4のNavigationLinkは、UIKitのnavigationController.pushに相当する機能であり、以下のような形で記載します。

NavigationLink {
    // 遷移先のView
} label: {
    // 遷移元のView
}

データバインディング

SwiftUIでは、データバインディングの仕組みを採用しており、値を監視し変更があった場合にViewを自動更新することが可能です。

監視対象の値やオブジェクトに対して、@Stateや@ObservedObjectをつけることで、その値を監視します。この辺の違いは今回詳しく解説しません。

@ObservedObject var viewModel: ArticleListViewModel

ArticleListViewModelでは、監視対象となるdataSourceが定義しているため、この値が変更されることで、UIが自動更新されます。
キャッシュの取得後やFeed取得後に更新するように実装しています。

class ArticleListViewModel: ObservableObject, Identifiable {
    @Published var dataSource: [Article] = []
    // 〜略〜
}

全体

なんとなく見ただけで実装内容を察せると思いますが、

  • ※1 NavigationView(NavigationController的なもの)を一番上に配置
  • ※2 中にScrollViewを配置
  • ※3 ForEach で viewModel.dataSource の個数分ループさせる
  • ※4 要素をタップした際のリンク先を指定
  • ※5 タップ可能なViewを配置
  • ※6より下 Navigationバーの装飾(タイトルやボタン配置、スタイルの指定など)

というようなことを行っています。

bodyの下にあるGeometryReaderは、親のViewのサイズを得るために使います。
画面全体のViewのサイズを得ていますが、ここでは扱わずにArticleRowに値を渡していますね。
親のサイズを得るなら、ArticleRow内でGeometryReaderを使えばいいんじゃないかと思われますが、これには少し理由があり後ほど説明します。

UIKit版

同じようなものを、UIKit版では以下のように実装しました。

SwiftUIに比べて倍近く行数が多いですね。
今回は実装の簡易性を加味しTableViewで実装しました。そのため、タップした時の反応がSwiftUI版と違ったりします。
UIkitには、値を監視して自動で更新してくれるような仕組みはないです。
そのため、SwiftUIと同じViewのロジックで実装するために、dataSourceを購読し、変更があったらtableview.reloadDate()を行っています。

func bind() {
    viewModel
        .$dataSource
        .sink(
            receiveCompletion: { _ in },
            receiveValue: { [weak self] value in
                self?.dataSource = value
                self?.tableView.reloadData()
            }
        )
        .store(in: &disposables)
}

今回main.storyboardを使わず、AppDelegateでコードでRootのWindowを初期化してます。
Viewの話とは全然関係ない事情なのですが、UsecaseにDIを採用しており、ViewControllerの初期化時に依存性の注入を行いたかったので、このような形にしました。

class ArticleUsecase {
    private let remoteRepository: RemoteArticleRepository
    private let cacheRepository: ArticleCacheRepository

    init(
        remoteRepository: RemoteArticleRepository,
        cacheRepository: ArticleCacheRepository
    ) {
        self.remoteRepository = remoteRepository
        self.cacheRepository = cacheRepository
    }
    //〜略
}
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?

    func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        window = UIWindow(frame: UIScreen.main.bounds)
        let usecase = ArticleUsecase(remoteRepository: RemoteArticleRepositoryImpl(),
        cacheRepository: ArticleCacheRepositoryImpl())
        let viewModel = ArticleListViewModel(usecase: usecase)
        let vc = ArticleListViewController(viewModel: viewModel)
        let navigationController = UINavigationController(rootViewController: vc)
        window?.rootViewController = navigationController
        window?.makeKeyAndVisible()

        return true
    }
}

各記事パーツについて

この部分についてとなります。
もともとは、左端に写真があって、右に説明があるようなよくあるリスト表示にする予定でした。
が、やってるうちに興が乗って、最終的にマテリアルデザインのCardっぽいデザインにしました。

SwiftUI版

SwiftUI版です。

VStack、HStack

新たな構成要素としてVStackHStackが登場してきました。
これは単に、VStack内の要素を縦に並べる、HStack内の要素を横に並べるコンテナとなっています。分かりやすいですね。
HStackの中括弧などに.font(.subheadline)などmodifierを追加していますが、これは中括弧内全ての要素にmodifierが適用されます。

HStack{
Text("hogeA") // font(.subheadline)が適用
Text("hogeB") // font(.subheadline)が適用
Text("hogeC") // font(.subheadline)が適用
}
.font(.subheadline)

画像について

ArticleRowにもViewModelを設けて値を監視しています。

なんとなく当初の想定では、記事は上から降ってくるArticleをそのまま表示すれば良いだけなので、値を監視する必要はないと思っていました。が、SwiftUIのImageはUIKitのUIImageのようにURL指定で画像を自動で取ってくるAPIはないため、画像の取得も行う必要がありました。そのためViewModelにて画像の取得を行い、画像の状態を監視し、取得前と取得後で表示する要素を分けています。

まぁ、UIKitでもこの手のリスト表示の場合は大抵画像をキャッシュしたりするので、UIImageのurl指定をそのまま使うことはそうないですが、手軽に確認したい時には少し面倒です。

if let image = viewModel.image {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
Rectangle()
.fill(Color.gray)
.aspectRatio(3 / 2, contentMode: .fit)
}

タグ構造について

タグ構造については、こちらを参考にしました。
各カテゴリのitemを並べつつはみ出したら下に移動しています。

タグ表示の部分だけ抜き出して、以下に示します。

private func categories(maxWidth: CGFloat) -> some View {
    var width = CGFloat.zero
    var height = CGFloat.zero

    return ZStack(alignment: .topLeading) {
        ForEach(viewModel.article.categories, id: \.self) { category in
            categoryItem(for: category)
                .padding(.all, 4)
                .alignmentGuide(.leading) { d in
                    if abs(width - d.width) > maxWidth {
                        width = 0
                        height -= d.height
                    }
                    let result = width
                    if category == viewModel.article.categories.last {
                        width = 0
                    } else {
                        width -= d.width
                    }
                    return result
                }
                .alignmentGuide(.top) { _ in
                    let result = height
                    if category == viewModel.article.categories.last {
                        height = 0
                    }
                    return result
            }
        }
    }
}

func categoryItem(for text: String) -> some View {
    Text(text)
        .padding(.all, 5)
        .font(.footnote)
        .background(.blue)
        .foregroundColor(.white)
        .cornerRadius(10)
    }
}

まず、ZStackですが、Viewを重ねて表示するためのコンテナとなります。alignmentに.topLeadingを指定しているので、その下で、
ForEach(viewModel.article.categories, id: \.self) { category in categoryItem(for: category)
でループさせてカテゴリのアイテムを配置していますが、一旦左上に重ねて並ぶことになります。

alignmentGuideは、クロージャー内から渡された計算に従って、Viewを再設置するmodifiedになります。

クロージャー内で渡されるdViewDimensions型となっており、対象のViewのサイズや再設置前のオフセットのガイド値を取得できます。

上のalignmentGuideでは、最左からの横方向にどのくらい移動するかを返しており、下のalignmentGuideでは、最上からの縦方向にどのくらい移動するかを返しています。

タグの改行の基準となるWidthは親のViewから受け取ってますが、以前の項目で、GeometryReaderを一番てっぺんに追加し、Viewのサイズを得ていました。

GeometryReader { geometry in }の括弧で囲まれた内と外で実行タイミングが異なり、外のレイアウトが確定した後、内側のレイアウトが確定するため、ここで挿入するとタグ部分がカードからはみ出すことになります。
そのため、アプリの一番てっぺんで画面サイズを得るようにしています。

UIKit版

UIKit版です。

合計300行近いコードになってしまいました。

TableViewCellとして定義しています。
各要素を並べて、レイアウトを行っている部分は以下のようになります。

contentView.addSubview(containerView)
containerView.addSubview(titleLabel)
containerView.addSubview(autherLabel)
containerView.addSubview(dateLabel)
containerView.addSubview(imageBaseView)
containerView.addSubview(summaryLabel)
containerView.addSubview(tabCollectionView)

containerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12.0)
    .isActive = true
containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12.0)
    .isActive = true
containerView.leftAnchor.constraint(equalTo: contentView.leftAnchor, constant: 12.0)
    .isActive = true
containerView.rightAnchor.constraint(equalTo: contentView.rightAnchor, constant: -12.0)
    .isActive = true

titleLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 8.0)
    .isActive = true
titleLabel.leftAnchor.constraint(equalTo: containerView.leftAnchor, constant: 8.0)
    .isActive = true
titleLabel.rightAnchor.constraint(equalTo: containerView.rightAnchor, constant: -8.0)
    .isActive = true

autherLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4.0)
    .isActive = true
autherLabel.leftAnchor.constraint(equalTo: containerView.leftAnchor, constant: 8.0)
    .isActive = true

dateLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4.0)
    .isActive = true
dateLabel.rightAnchor.constraint(equalTo: containerView.rightAnchor, constant: -8.0)
    .isActive = true

imageBaseView.topAnchor.constraint(equalTo: autherLabel.bottomAnchor, constant: 8.0)
    .isActive = true
imageBaseView.leftAnchor.constraint(equalTo: containerView.leftAnchor)
    .isActive = true
imageBaseView.rightAnchor.constraint(equalTo: containerView.rightAnchor)
    .isActive = true

summaryLabel.topAnchor.constraint(equalTo: imageBaseView.bottomAnchor, constant: 8.0)
    .isActive = true
summaryLabel.leftAnchor.constraint(equalTo: containerView.leftAnchor, constant: 8.0)
    .isActive = true
summaryLabel.rightAnchor.constraint(equalTo: containerView.rightAnchor, constant: -8.0)
    .isActive = true

tabCollectionView.topAnchor.constraint(equalTo: summaryLabel.bottomAnchor, constant: 4.0)
    .isActive = true
tabCollectionView.leftAnchor.constraint(equalTo: containerView.leftAnchor, constant: 8.0)
    .isActive = true
tabCollectionView.rightAnchor.constraint(equalTo: containerView.rightAnchor, constant: -8.0)
    .isActive = true
tabCollectionView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -8.0)
    .isActive = true

// reloadData後でないとcollectionViewの高さが確定しないため、一時的なサイズを設定(二行分)
let tempHeightConst = tabCollectionView.heightAnchor.constraint(equalToConstant: 50)
tempHeightConst.isActive = true

// See: https://qiita.com/ponkichi4/items/d5d46556773a6bc98f9c
UIView.animate(withDuration: 0.0, animations: {
    self.tabCollectionView.reloadData()
    }) { _ in
        tempHeightConst.isActive = false
        self.tabCollectionView.heightAnchor.constraint(equalToConstant:
        self.tabCollectionView.collectionViewLayout.collectionViewContentSize.height)
             .isActive = true
}

ある程度オートレイアウトの知見がある人でないと、コードで書くのは難しいと思います。SwiftUIに比べて習得難度は高そうです。

インターフェースビルダーを使って書くことができますが、少し問題を抱えています(後述します)。
最初はxibで書いていたのですが、このレイアウトはコンテンツが挿入されるまでサイズが確定しないので、xib上でエラーになるのが嫌でコードで書き直しました。

こちらの記事を参考にタグ部分はUICollectionViewで作成しました。
CollectionViewのサイズをcontentSizeいっぱいに広げたいのですが、reloadDataの後でないと、サイズが確定しないため、reloadData後にサイズの制約を設定しています。

UIView.animate(withDuration: 0.0, animations: {
    self.tabCollectionView.reloadData()
}) { _ in
    tempHeightConst.isActive = false
    self.tabCollectionView.heightAnchor.constraint(equalToConstant:
    self.tabCollectionView.collectionViewLayout.collectionViewContentSize.height)
        .isActive = true
}

詳細画面

詳細画面はWebViewを配置しただけのシンプルな画面となっています。

SwiftUI版

SwiftUI版は以下のようなコードになっています。

SwiftUIでは、WebViewに相当する機能がありません。
そのため、WebKitのWKWebViewをSwiftUIで使えるようにする必要があります。
そこで登場するのが、UIViewRepresentableとなります。

SwiftUIにてUIKitのViewを使用するためのラッパーです。
UIViewRepresentableでラップすることで、SwiftUIでWKWebViewが表示できるようになります。
func makeUIView(context: Self.Context) -> Self.UIViewType

では、Viewの作成時に呼ばれ、該当のViewのインスタンスを返します。
func updateUIView(_ uiView: Self.UIViewType, context: Self.Context)
は、Viewの更新時に呼ばれ、更新処理を記載します。特に今回は何もしません。

受動的なViewではなく、このViewから何らかのアクションを行う必要がある場合には、Coordinatorという仕組みを使うことで実現可能です。
今回実装していないので割愛します。

UIKit版

UIKit版は以下のように実装しています。
iOSアプリの開発者なら誰もが実装したことある内容かと思いますので、特に語ることはないですね。

その他、気になった点

プレビュー機能について

SwiftUIには、プレビュー機能がついており、実行しなくても見た目が確認できるようになっています。
簡単に任意の値を流せたりするので結構便利です。

ただ、結構動作が不安定で長く開いていると、謎のエラーで使えなくなったりします(Xcodeの再起動で直ったりもします)。

NavigationStackについて

以前の項目でNavigationController.pushに相当する機能として、NavigationLinkがあると記載しました。

NavigationLink {
    // 遷移先のView
} label: {
    // 遷移元のView
}

問題点として、2画面先に移動するなどの、2つ戻って1画面追加するような複雑な画面遷移の管理は一工夫が必要なようです。

しかし、実はiOS16から、NavigationがDeprecatedとなり、新たにNavigationStackが追加されました。
(執筆時点では、まだベータ版でしか使えないので例としては載せていません)

@State private var presentedParks: [Park] = []

NavigationStack(path: $presentedParks) {
    List(parks) { park in
        NavigationLink(park.name, value: park)
    }
    .navigationDestination(for: Park.self) { park in
        ParkDetails(park: park)
    }
}

presentedParksのようにリスト形式で定義し、NavigationStackにバインドすることで、ViewStackを管理し、画面遷移を追従することができます。
また、新しいViewStackを生成することもできます。

func showParks() {
    presentedParks = [Park("Yosemite"), Park("Sequoia")]
}

かなり画面遷移の管理がやりやすくなると思います。
惜しいのは、iOS16以降対応なので、しばらくは使えないことでしょうか...。

UIKitでSwiftUIを使用する

記事内では、SwiftUIの中で、UIkitを利用する方法を提示しましたが、逆にUIkitの中でSwiftUIを使う方法もあります。
その場合は、UIHostingControllerを使います。

struct SwiftUIView : View {
    var body: some View {
        Text("SwiftUI View")
    }
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        let vc: UIHostingController = UIHostingController(rootView: SwiftUIView())
        self.addChild(vc)
        〜 略 〜
    }
}

ScrollViewについて

今回SwiftUIのlistではなく、ScrollViewに記事のアイテムを置いたのですが、後々調べてみるとこのやり方はあまりよろしくなさそうです。

ScrollViewだと設置したViewを一度にレンダリングしてしまうので、多くのViewを配置するとメモリが半端なく食ってしまうようです(今回は最大10件しか記事を取得できないのでそこまで問題はないですが)。

ListFromでは、画面に表示されているView分しかメモリを消費しないようなので、幾つもViewを並べる場合には、なるべくこちらを使うようにしたいです。

比較してみた際のSwiftUIのメリット・デメリット

メリット

簡単かつ簡潔

SwiftUIは、マークアップ言語に近い感じで実装できるので、UIkitのオートレイアウトと比べて、比較的習得難易度が低いと思いました。
また、Viewの部分のコード量については、圧倒的にSwiftUIの方が少ない行数で書くことが可能です。

プレビュー機能

プレビュー機能により、いちいちビルドしなくても見た目を確認できるのは、便利だなと思います。

Storyboardのコンフリクト問題からの解放

StoryboardでUIを構築する場合、Storyboard自体はひとつの大きなXMLファイルですが、開くだけで何かしらの変更が発生してしまったり、開発している箇所と全然関係ない箇所に変更が発生してしまったりで、複数人で開発するとStoryboardのコンフリクト問題によく遭遇します。

また、StoryboardのXMLは、インターフェースビルダーで読み込むようにできており、基本人が読む形ではないため(読めなくはないでしょうが)、競合が発生すると解決がかなり大変です。

弊社では、上の問題について1画面に1つのStoryboardを使用する、Storyboardを使用せずコードのみで実装するなどの工夫で解決を図ってきました。

SwiftUIではコードベースのため、開くだけで何か変わったり、ちょっとクリックしただけで変更が発生するようなことはなく、また競合が発生しても分かりやすいため、その点は優れていると思います。

データバインディング

データの更新をViewに反映する処理を書かなくてよくなるため簡潔に書けます。
Viewとロジックの分離もしやすくなります。

デメリット

iOS13以降という制約がある

そのため、iOS12以下では使用できません。

機能的な不足がある

現状、SwiftUIのみでアプリを構築するのは難しく、UIKitを交えたアプリの開発が必須になると思います。
特に画面遷移の仕組みがiOS16までは、足りない点が多いAPIを使用しなければならず、そこは致命的だと思いました。

現状では画面遷移の管理にはNavigationControllerを使わざるを得ないと思います。

結論

SwiftUIを使うことで、Viewのレイアウトにおいて、比較的スピーディーにかつ可読性の高いコードを描くことが可能であると思いました。

一方、SwiftUIのみでは機能が不足しがちで、特にNavigationControllerの代替となるNavigationに関してはかなり貧弱です。
開発の途中で急に一方通行ではない形の画面遷移を求められた場合などに対応が難しくなりそうです。

そのため、現状ではベースをUIKitで作り、画面遷移の仕組みとしてはNavigationControllerを使いつつ、ViewのレイアウトをSwiftUIで作るのが現実的な選択肢になるのかなという気がしています。



CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。