ZOZOTOWN iOSホーム画面リアーキテクチャの軌跡 ── 失敗から学び成長した1年半

ZOZOTOWN iOSホーム画面リアーキテクチャの軌跡 ── 失敗から学び成長した1年半

はじめに

こんにちは、ZOZOTOWN開発1部iOSブロックの荻野です(@juginon)。

みなさんに日々使っていただいているZOZOTOWN iOSアプリのホーム画面ですが、実は2024年秋から2026年の年初まで約1年半、水面下でリアーキテクチャを行っていました。

リアーキテクチャに着手する前の当時の私はアーキテクチャ設計への理解がまだ浅く、「実際に手を動かしながら身につけたい」という動機でこのリアーキテクチャを主導しました。自分にとってはチャレンジングな取り組みで、アーキテクチャ設計やテスト設計への理解が実践を通して大きく深まったプロジェクトになりました。

本記事では、そのリアーキテクチャのすべての軌跡と、そこで得た学びをお伝えします。

なお、本記事で紹介するホーム画面リファクタリングは、iOSチーム全体で取り組んでいるアーキテクチャ刷新の具体的な事例の1つでもあります。チームとしての取り組みや知識共有の仕組みについてはZOZOTOWNのiOSアーキテクチャとチーム進化の軌跡にもまとめています。本記事と合わせて読むと、個々の取り組みとチーム全体の文脈をより立体的に理解できます。

続きを読む

スキーマでアーキテクチャを縛る ── 人間とAIを同じルールで動かす

スキーマでアーキテクチャを縛る ── 人間とAIを同じルールで動かす

はじめに

こんにちは、ZOZOTOWN開発1部iOSブロックの@kitasukeです。

前回の記事「ZOZOTOWN iOS のアーキテクチャとチームの進化」では、MVCからMVVM、そしてMVVM + Repositoryへのアーキテクチャ進化を取り上げました。あわせて、レビュー文化をチームに根づかせてきた3年間も振り返っています。

ただ、アーキテクチャを文章で定義しても、書き手によって命名や責務分割はぶれが生じますし、AIに任せると過去の望ましくない実装パターンまで律儀に再現されます。ドキュメントによる「努力目標」では、アーキテクチャは守りきれません。

そこで発想を逆にしました。アーキテクチャを「守るべきルール」ではなく、構造化されたスキーマとして定義し、人間とAIの双方がそれに従うしかない形にします。Swiftの型システムがコンパイル時に不正を弾くのと同じ発想を、アーキテクチャのレイヤーにスキーマという形で持ち込みます。それが本記事で紹介する「スキーマでアーキテクチャを縛る」アプローチです。副産物として、設計からコードを自動生成するパイプラインも動いています。

続きを読む

try! Swift Tokyo 2026に採択・登壇するまでの舞台裏

try! Swift Tokyo 2026に採択・登壇するまでの舞台裏

はじめに

こんにちは、ZOZOTOWN開発本部でiOSエンジニアをしている續橋(@tsuzuki817)です。

2026年4月13日〜14日に開催されたtry! Swift Tokyo 2026にて、「GeoJSON×SwiftUI:地図を“美しく”描くための技術」というタイトルで20分のトークをしました。

speakerdeck.com

www.youtube.com

本記事では、プロポーザルの準備から採択、トーク作成、社内での練習とフィードバック、そして登壇当日までの道のりをお伝えします。これからカンファレンスへの登壇を考えている方の参考になれば幸いです。

try! Swift Tokyo 2026 登壇の様子

続きを読む

Swift 6移行で約1400個の警告に対応して得た知見

Swift 6移行で約1400個の警告に対応して得た知見

はじめに

こんにちは、FAANS部フロントエンドブロックの中島です。普段はFAANSのiOSアプリ開発を担当しています。FAANS iOSチームではSwift 6移行の取り組みをしています。以前、Strict Concurrency CheckingをTargetedに変更した過程で得た知見を紹介しました。今回TargetedからCompleteに変更するとXcodeで約1400個の新たな警告が出ました。機械的に対応できる警告もありますが、曖昧な知識だと修正が難しいケースもありました。本記事では、Swift 6移行時の警告やエラー解決を通じて得た知見を共有します。実際に遭遇した警告への対処法など、移行作業を始める前に押さえておきたかったポイントを中心に解説します。

移行当初はXcode 16.4だったので、最新のXcodeでは警告がエラーとなる可能性もありますが、本記事では警告で統一します。また、Swift 6でビルドをするとそれまで警告だったものがエラーになりビルド自体が通らなくなるため、まずはSwift 5の段階ですべての警告を解消しました。その後、Swift 6へ切り替えてビルドが通ることを確認し、新たに不具合が発生していないかを検証しました。

続きを読む

ZOZOTOWN iOSアーキテクチャの進化とチームの変化 ── 「できる人がやる」から全員で設計をレビューできるようになるまでの軌跡

ZOZOTOWN iOSアーキテクチャの進化とチームの変化 ── 「できる人がやる」から全員で設計をレビューできるようになるまでの軌跡

はじめに

こんにちは、ZOZOTOWN開発2部iOSブロックのらぷ(@laprasdrum)です。普段はZOZOTOWN iOSアプリを開発するチームで各メンバーの開発における設計や技術課題のフォローアップを担当しています。また、iOS領域におけるテックリードとして社内の技術共有会やZOZO.swiftなどを運営しており、各プロダクトのiOSチーム全体をつなげる横断活動に従事しています。

ZOZOTOWN iOSアプリは2010年11月にリリースされ、15年以上にわたって開発が続くプロダクトです。長い歴史の中でチームと技術が変遷し続け、Fat ViewControllerやObjective-Cコードの残存といった技術的負債を抱えていました。これに対してチームは2023年からアーキテクチャの刷新に本格的に取り組んできました。

本記事では、その3年間の変遷を振り返り、アーキテクチャがどのように進化し、設計をレビューする力がチーム全体にどう広がったかをお伝えします。

なお、チーム運営の全体像はZOZOTOWNのiOSチームを支えるチーム運用で紹介しています。Fat ViewController解消の具体的な手法はZOZOTOWN iOSアプリでのFat ViewController解消への取り組みを参照してください。

続きを読む

DGChartsからSwift Chartsへの移行で検討した3つの実装アプローチ

DGChartsからSwift Chartsへの移行で検討した3つの実装アプローチ

はじめに

こんにちは、FAANS部フロントエンドブロックの加藤です。普段はFAANSのiOSアプリを開発しています。FAANSは、ショップスタッフの販売サポートツールであり、アプリ上でコーディネートの投稿や売上などの成果を確認できます。

成果の確認画面では以下の動画のように成果を棒グラフで可視化しています。これまでFAANS iOSでは、棒グラフの生成にサードパーティライブラリであるDGChartsを用いていました。一方で、FAANSではiOS 15のサポートを終了しているため、iOS 16以上で利用可能なApple標準のグラフ生成フレームワーク「Swift Charts」を利用できます。そこで、この度、DGChartsからSwift Chartsへの移行を実施しました。

この記事では、DGChartsからSwift Chartsへの移行にあたり検討した実装アプローチについて紹介します。

DGChartsを利用した成果画面

目次

成果画面のレイアウトと機能

FAANSにおける成果画面のレイアウトと機能は以下の画像のようになっています。 成果画面の機能とレイアウト

成果画面では、横軸が日付、縦軸が売上の棒グラフが表示されます。棒グラフは横方向のスクロール(画像の1)、およびタップが可能で、選択した日付の売上が画面上に表示される仕組みです(画像の2)。また、棒グラフは3〜4種類の値で構成されており、それぞれの値を色分けして積み上げています(画像の3)。さらに、棒グラフは1画面に7.5日分表示されており、左端に0.5日分が見切れた状態です。これにより、スクロールが可能であることを示唆しています(画像の4)。

以上がFAANSの成果画面におけるレイアウトと機能です。本記事では、これらの機能をSwift Chartsで実装するにあたり検討した3つのアプローチについて、比較・検証した過程を紹介します。

実装方法は以下の3つです。

  • Swift Chartsのみで実装する方法
  • Swift ChartsとUICollectionViewを組み合わせて実装する方法(今回採用した方法)
  • 表示するデータを工夫したSwift Chartsの実装方法(採用には至らなかったが、Swift Chartsのみで完結させる代替案として紹介)

また、実装要件と3つの実装方法に対する評価方法は以下の通りです。

  • 実装要件
    • 横スクロール、タップアクション、値の積み上げ、7.5日分の表示の4種類の機能を実装する
  • 評価方法
    • InstrumentsのHitches(フレームの描画遅延の回数・タイミングを可視化するツール)
    • 検証端末:iPhone 16 Pro(iOS 26.2.1)

Swift Chartsのみで実装

まずはSwift Chartsのみで実装する方法についてです。プログラムは以下の通りです。

// グラフデータの構造体
struct Sales: Identifiable {
    var id = UUID()
    var type: String
    var date: Date
    var sales: Double
}
private let salesChannels = ["zozotown", "wear", "yahoo!Shopping", "ownedEc"]

//------以下、グラフの生成
struct BarChartsView: View {
    private let visibleLength: TimeInterval = 24 * 60 * 60 * 7.5
    private let dateFormatter = DateFormatter(with: .weeklyChart) // 自作の拡張
    // データの作成
    private let barData: [Sales] = [
        (month: 9, days: 1...30),
        (month: 10, days: 1...30)
    ].flatMap { month, days in
        days.flatMap { day -> [Sales] in
            salesChannels.map { type in
                Sales(
                    type: type,
                    date: date(year: 2025, month: month, day: day), // Dateの作成
                    sales: round(Double.random(in: 0...50000000))
                )
            }
        }
    }

    @State private var scrollPosition: Date = barData.last!.date

    var body: some View {
        Chart(barData, id: \.id) { row in
            BarMark(
                x: .value("Day", row.date, unit: .day), // x座標のデータ(日付)
                y: .value("Sales", row.sales) // y座標のデータ(売上)
            )
            .foregroundStyle(by: .value("Type", row.type)) // ③データの積み上げ
        }
        .chartScrollableAxes(.horizontal) // ①横方向のスクロール方向(iOS 17+)
        .chartLegend(.hidden) // 凡例の非表示
        .chartXVisibleDomain(length: visibleLength) // ④可視化幅を7.5日分に設定(iOS 17+)
        .chartScrollPosition(x: $scrollPosition) // 最初に右端が映るように設定(iOS 17+)
        // 積み上げる色の定義
        .chartForegroundStyleScale([
            "zozotown": Color(.Token.serviceZozotown),
            "wear": Color(.Token.serviceWear),
            "yahoo!Shopping": Color(.Token.serviceYahoo),
            "ownedEc": Color(.Token.serviceBrandEc)
        ])
        // ②グラフタップ時の挙動(iOS 17+)
        .chartGesture { chart in
            SpatialTapGesture()
                .onEnded { value in
                    guard
                        let (date, _) = chart.value(
                            at: value.location,
                            as: (Date, Double).self
                        )
                    else { return }
                    // ↑dateがタップした日付
                }
        }
        // x軸のラベル定義
        .chartXAxis {
            AxisMarks(values: .stride(by: .day)) { value in
                if let date = value.as(Date.self) {
                    AxisValueLabel(centered: true) {
                        Text(dateFormatter.string(from: date)) // MM/dd\nEEE
                            .multilineTextAlignment(.center)
                    }
                }
            }
        }
        // y軸のラベル定義
        .chartYAxis {
            AxisMarks(values: .automatic(desiredCount: 4)) { value in
                AxisValueLabel(multiLabelAlignment: .leading) {
                    if let raw = value.as(Double.self) {
                        Text(
                            // 中身は省略
                        )
                    }
                }
            }
        }
    }
}

上記プログラムでは、横スクロール、タップアクション、値の積み上げ、7.5日分の表示の4種類の機能をそれぞれ以下の方法で実装しています。

  • 横スクロール:chartScrollableAxes(.horizontal)
  • タップアクション:chartGesture
  • 値の積み上げ:foregroundStyle
  • 7.5日分の表示:chartXVisibleDomain

注意が必要なのは、chartScrollableAxeschartGestureはiOS 17以降で利用できる機能である点です。また、chartScrollPositionで初期の表示位置を指定している点や、chartXAxisやchartYAxisで目盛りのレイアウトを調整している点も重要です。

これで、実装したかった成果画面のレイアウトと機能を全て実装できました。しかし、スクロール時の動作を確認してみると、スクロールが重たく感じます。主観では判断できないため、InstrumentsのHitchesを用いてパフォーマンスを計測しました。パフォーマンス計測では、グラフの表示画面を表示して、数回のスクロールを実施しました。パフォーマンス計測結果は以下の画像のようになりました。 パフォーマンス計測(Swift Charts)

上記画像におけるタイムライン上の赤線は、フレームの描画遅延が発生した時刻を表しています。Swift Chartsのみの実装では赤線が密集しており、スクロール中に連続してフレームの描画遅延が発生していることが確認できました。また、サマリーを見ると338回発生しており、最大Hitchは25msでした。ここで比較のため、DGChartsを用いた既存実装におけるHitchesを示します。 パフォーマンス計測(DGCharts)

Swift Chartsのみで実装した場合と比較して、赤線が密集している箇所が少なく、最大Hitchも12.50msであることが分かります。

Swift Chartsのみで実装された場合におけるパフォーマンス低下の原因を調査した結果、データ数の多さ(約2か月分)が主な要因のようです。また、multilineTextAlignment(.center)の指定や、chartScrollPositionの利用も影響していました(正確な原因の特定には至りませんでした)。multilineTextAlignment(.center)をやめると軽くなりますが、データ数は減らせないので、Swift Chartsのみの実装方法は採用しませんでした。

Swift Charts + UICollectionViewで実装

Swift Chartsにおけるスクロールのパフォーマンス問題を解消するために、UICollectionViewを用いる方法を検討しました。具体的には、UICollectionViewのscrollDirectionで横スクロールを実現して、UICollectionViewCellとしてSwift Chartsを表示します。UICollectionViewはUICollectionViewCellを再利用して描画するため、データ量が多い場合でもパフォーマンスへの影響を抑えられます。これまでのDGChartsを用いた実装でも、この方法を採用していました。

また、UICollectionViewを用いた実装では、y軸を別途実装する必要があります。FAANSの成果画面では右端にy軸が固定されており、棒グラフのみがスクロールできるデザインです。そのため、UICollectionViewCellに載せるViewではy軸は非表示にして、別のViewとして実装する必要があります。図にすると下記のような構成です。 UICollectionViewとSwift Chartsを用いた実装の内訳

UICollectionViewCellに載せるSwift Chartsの実装は以下の通りです。

// 表示するデータのチャンネル
enum StackedOutcomeChannel: String, Plottable, CaseIterable {
    case zozotown
    case wear
    case yahooShopping
    case ownedEc
}

// グラフデータの構造体
struct StackedOutcomeBarMarkEntry: Hashable {
    var type: StackedOutcomeChannel
    var date: Date
    var value: Double
}

struct StackedOutcomeBarMarkView: View {
    // 外部から代入する値
    struct ChartModel {
        var colors: [UIColor]
        var entries: [StackedOutcomeBarMarkEntry]
        var yAxisMax: Double
        var selectedDate: Date?
        var onSelectDate: ((Date) -> Void)?
    }

    let chartModel: ChartModel
    @State private var selectDate: Date? // 選択されたグラフ日時の格納先

    // グラフの色(chartForegroundStyleScaleで利用するためにKeyValuePairsで定義)
    private var barMarkColors: KeyValuePairs<StackedOutcomeChannel, Color> {
        return [
            StackedOutcomeChannel.zozotown: Color(chartModel.colors[0]),
            StackedOutcomeChannel.wear: Color(chartModel.colors[1]),
            StackedOutcomeChannel.yahooShopping: Color(chartModel.colors[2]),
            StackedOutcomeChannel.ownedEc: Color(chartModel.colors[3])
        ]
    }

    init(chartModel: ChartModel) {
        self.chartModel = chartModel
        _selectDate = State(initialValue: chartModel.selectedDate)
    }

    var body: some View {
        Chart(chartModel.entries, id: \.self) { row in
            BarMark(
                x: .value("Day", row.date),
                y: .value("Value", row.value)
            )
            .foregroundStyle(by: .value("Type", row.type))
        }
        .chartLegend(.hidden) // 凡例の非表示
        .chartForegroundStyleScale(barMarkColors) // 積み上げる色の定義
        // グラフタップ時の挙動(iOS 17+)
        .chartGesture { chart in
            SpatialTapGesture()
                .onEnded { value in
                    guard
                        let (date, _) = chart.value(
                            at: value.location,
                            as: (Date, Double).self
                        )
                    else { return }
                    self.selectDate = date
                    chartModel.onSelectDate?(date)
                }
        }
        // x軸のラベル定義
        .chartXAxis {
            AxisMarks(values: .stride(by: .day)) { value in
                if let date = value.as(Date.self) {
                    AxisValueLabel(centered: true) {
                        Text(DateFormatter(with: .weeklyChart).string(from: date))
                            .multilineTextAlignment(.center)
                    }
                }
            }
        }
        .chartYScale(domain: 0...chartModel.yAxisMax)  // 重要: y軸スケールの定義
        .chartYAxis(.hidden) // y軸の非表示
    }
}

Swift Chartsのみで実装した場合と異なり、横スクロールの設定やchartScrollPositionによる初期位置の調整は不要です。また、y軸は非表示にしたいので、.chartYAxis(.hidden)を設定しています。このとき、chartYScaleを用いて、y軸の最小値と最大値を設定しておくことがポイントです。この定義で、独立したy軸のみのViewと棒グラフの目盛りの整合性を取ります。

続いて、右側に固定するy軸のViewを下記のプログラムで実装します。

struct BarMarkYAxis: View {
    // 外部から代入する値(仕様の関係)
    final class YAxisModel: ObservableObject {
        @Published var yAxisMax: Double = 100
    }

    @ObservedObject var model: YAxisModel = YAxisModel()

    var body: some View {
        Chart {
            // y軸最大値のルールの定義(あってもなくてもよい)
            RuleMark(y: .value("max", model.yAxisMax))
                .foregroundStyle(.clear)
        }
        .chartXAxis(.hidden) // x軸の非表示
        .chartYScale(domain: 0...model.yAxisMax) // y軸範囲の定義
        // y軸のラベル定義
        .chartYAxis {
            // おおよそ6つの目盛りで構成
            AxisMarks(values: .automatic(desiredCount: 6)) { value in
                // 補助線の非表示化
                AxisGridLine(stroke: StrokeStyle(lineWidth: 0))
                AxisValueLabel(multiLabelAlignment: .leading) {
                    if let raw = value.as(Double.self) {
                        Text(
                            // 中身は省略
                        )
                    }
                }
            }
        }
        .chartPlotStyle { plot in
            plot.frame(width: 0) // y軸だけ欲しいのでグラフのプロット幅を0に
        }
        .frame(width: 39)
    }
}

このプログラムでは、chartXAxis(.hidden)でx軸を非表示にしており、棒グラフとして表示するデータも与えていません。一方で、これだけではグラフのプロット領域が確保されてしまうので、chartPlotStyleplot.frame(width: 0)を定義して、プロット領域の幅を0にしています。また、Swift ChartsのViewと同様にchartYScaleを定義しており、chartYAxisでy軸の目盛りを設定しています。加えて、chartYAxis内のAxisMarks(values: .automatic(desiredCount: 6))で、おおよそ6つの目盛りをy軸上に表示しています。

以上のSwift ChartsのViewをCellとしたUICollectionViewと、Swift Chartsで作成したy軸を組み合わせて実装した成果画面の完成版が下記の動画です。最初に述べたFAANSにおけるレイアウトと機能を実装できていることが確認できます。

完成した成果画面

また、InstrumentsのHitchesを用いてパフォーマンスを計測した結果、以下の画像のように赤線の密集が少なく、パフォーマンスの著しい低下が発生していないことが確認できました。 パフォーマンス計測(Swift Charts+UICollectionView)

Swift Charts + 表示データの工夫で実装

先に述べた通り、Swift Chartsのみの実装では横スクロールが重たく感じる事象を確認したため、UICollectionViewと組み合わせた方法を採用しました。一方で、UICollectionViewを使わずSwift Chartsのみで完結させたいケースもあるかと思います。そこで、一度に渡すデータ量を制限すればスクロール時のパフォーマンス低下を緩和できると考え、試作しました。今回は採用に至りませんでしたが、Swift Chartsのみで実装する際の代替案として紹介します。データ量の制限方法は以下の図の通りです。

表示するデータ範囲遷移の模式図

図の例では、1/31をデータの最終日とした場合、最初に1/31から1か月前までのデータをSwift Chartsに渡します(図の上段)。その後、ユーザが1/1までスクロールした際には、1/1を中心とした前後15日分、すなわち合計30日分(約1か月)を新たな表示データとしてSwift Chartsに渡します(図の下段)。このように実装することで、Swift Chartsは常に1か月分のデータのみ描画することになり、大量データを渡したときと比較して、スクロールが重くなりにくいと考えられます。実装は下記の通りです。

struct BarChartsView: View {
    private let visibleLength: TimeInterval = 24 * 60 * 60 * 7.5
    private let stopDebounce: TimeInterval = 0.25

    private let dateFormatter = DateFormatter(with: .weeklyChart)

    @State private var scrollPosition: Date = barData.last!.date // barDataは1つ目の実装例と同様の定義
    @State private var scrollStopTask: Task<Void, Never>?
    @State private var visibleData: [Sales] = [] // 表示するデータを格納(1か月分)
    @State private var pendingScrollTarget: Date?
    @State private var isProgrammaticScroll = false
    @State private var chartEpoch: Int = 0

    init() {
        let center = barData.last!.date
        _visibleData = State(initialValue: extractWindowData(around: center))
    }

    var body: some View {
        Chart(visibleData, id: \.id) { row in
            BarMark(
                x: .value("Day", row.date, unit: .day),
                y: .value("Sales", row.sales)
            )
            .foregroundStyle(by: .value("Type", row.type))
        }
        .id(chartEpoch) // visibleData差し替え時にChartも再構築
        .chartScrollableAxes(.horizontal)
        .chartLegend(.hidden)
        .chartXVisibleDomain(length: visibleLength)
        .chartScrollPosition(x: $scrollPosition)
        // スクロール時に左端のグラフが見切れる位置で止まるように制御(iOS 17+)
        .chartScrollTargetBehavior(
            .valueAligned(matching: DateComponents(hour: 12, minute: 0, second: 0))
        )
        .chartForegroundStyleScale([
            // (省略)
        ])
        .chartXAxis {
            // (省略)
        }
        .chartYAxis {
            // (省略)
        }
        .onChange(of: scrollPosition) { _, newValue in
            // 自動スクロールでscrollPositionが更新された場合、scrollStopCheckを呼ばない
            if isProgrammaticScroll {
                isProgrammaticScroll = false
                return
            }
            // ユーザ操作でスクロールされた際に呼び出し
            scrollStopCheck(after: stopDebounce)
        }
        // 表示するデータの差し替え後に、差し替え前に表示していた位置に遷移
        .onChange(of: visibleData) { _, _ in
            guard let target = pendingScrollTarget else { return }
            pendingScrollTarget = nil

            Task { @MainActor in
                isProgrammaticScroll = true
                scrollPosition = target
            }
        }
    }

    // グラフがスクロールされた場合の処置
    func scrollStopCheck(after delay: TimeInterval) {
        scrollStopTask?.cancel()
        scrollStopTask = Task { @MainActor in
            // Task.sleepで待機中に次のタスクが来たら前のタスクをキャンセル
            do {
                try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
            } catch { return }
            guard !Task.isCancelled else { return }

            let center = alignToNoon(scrollPosition) // データ更新後の遷移先の指定
            let next = extractWindowData(around: center) // 新たなデータの抽出(centerを中心として前後15日のおよそ1か月分)

            chartEpoch += 1 // idの更新

            visibleData = next // 表示するデータ位置の更新
            let pendingPosition = Calendar.current.date(byAdding: .day, value: 1, to: center)!
            pendingScrollTarget = pendingPosition // データ更新後の遷移位置の指定
        }
    }

    // 引数: centerの値から前後15日分の1か月分を親配列から抽出
    func extractWindowData(around center: Date, days: Int = 15) -> [Sales] {
        let cal = Calendar.current
        let start = cal.date(byAdding: .day, value: -days, to: center) ?? center
        let end = cal.date(byAdding: .day, value:  days, to: center) ?? center
        return barData.filter { $0.date >= start && $0.date <= end }
    }

    // 入力されたDateの時間を12時に固定
    func alignToNoon(_ date: Date) -> Date {
        var comps = Calendar.current.dateComponents([.year, .month, .day], from: date)
        comps.hour = 12
        comps.minute = 0
        comps.second = 0
        return Calendar.current.date(from: comps) ?? date
    }
}

上記プログラムのポイントは、以下の3つです。

  • chartScrollPositionによるスクロールの監視
  • 表示データとChartのidの更新
  • scrollPositionによるグラフ位置の調整

まず、chartScrollPositionscrollPositionの変数を設定して、現在のスクロール位置を監視します(ポイント1)。スクロールがあった場合には、onChange(of: scrollPosition)が呼ばれ、内部に定義されているscrollStopCheck(after: stopDebounce)が呼ばれます。この関数では、スクロール後、一定の時間静止した場合に表示データを更新します。更新後のデータは、extractWindowDataという自作の関数を用いて取得しています。また、データの更新時にはchartEpochを更新してChart自体を新しく構築し直す必要があります(ポイント2)。Chartを再構築しない場合、データを更新する度に、Chartのスクロールが重くなっていきます。

最後にデータを更新した際の表示位置を調整します。表示位置を調整せず、データの更新のみを行った場合、更新前に表示されていた日付からずれます。これは、Swift Chartsがスクロール位置を座標として記録しているためです。例えば、先ほどの図の上段において1/1までスクロールしたとします。すなわち、左端のデータが表示されている状態です。この状態で図の下段のようにデータを更新すると、左端のデータがそのまま表示されるので、1/1ではなく、12/16が表示されてしまいます。データ更新後も1/1が表示されている状態を維持したいので、データ更新前の表示位置をあらかじめ記録します。上記プログラムでは、pendingScrollTargetに表示位置を記録しています。そして、記録した表示位置を用いて、scrollPositionを更新することでデータ更新後の表示位置を調整します。

また、InstrumentsのHitchesを用いてパフォーマンスを計測した結果、下記画像に示すように赤線の密集が発生していません。すなわち、データの量を制限していない場合と比較して、大幅にパフォーマンスを改善できていることが確認できました。 パフォーマンス計測(Swift Charts+データアレンジ)

このプログラムを用いることでSwift Chartsのみで実装できます。一方で、chartScrollPositionはスクロール位置の同期が主な用途です(公式ドキュメント)。そのため、データ差し替え後の位置制御に用いる場合は意図しない挙動が発生するかもしれません。また、端までスクロールした際にデータを更新すると、見切れている棒グラフとの位置関係によるグラフのずれが発生します。採用には注意が必要です。

DGChartsとSwift Chartsの比較

最後に、Swift Chartsへの置き換えで学んだDGChartsとSwift Chartsの違いを表で示します。基本的にはApple純正のフレームワークであるSwift Chartsを用いるのが良いと考えています。

項目 DGCharts Swift Charts
フレームワーク種別 サードパーティ Apple純正
対応OS iOS 12+ iOS 16+
UI基盤 UIKit SwiftUI
積み上げ棒グラフの実現方法 x座標を指定して、積み上げる値の配列を渡す 配列内でx座標が同じ要素を重ねて表示
グラフのハイライト色指定 highlightAlphaで色の指定 専用の色指定APIはない
スクロール挙動の制御 スナップやページングは自前実装が必要 .chartScrollTargetBehaviorで単位揃えやスナップを指定可能(iOS 17+)
大量データのスクロール(パフォーマンスの問題) UICollectionViewのセル再利用により、大量データでもパフォーマンスの問題は発生しにくい 標準の横スクロール(chartScrollableAxes)では大量データで描画遅延が発生。UICollectionViewとの併用や表示データ量の制限で対処が必要

まとめ

本記事では、DGChartsからSwift Chartsへの移行にあたり、3つの実装アプローチを比較・検証した過程を紹介しました。

Swift Chartsは宣言的な記述で手軽にグラフを実装できる一方、大量データのスクロール描画ではパフォーマンス上の課題があります。そのため、UICollectionViewとの併用やデータの動的な差し替えといった工夫が求められる場面もあります。今回はUICollectionViewとの組み合わせを採用しましたが、要件やデータ量に応じて最適な方法は異なるため、本記事で紹介した各アプローチが実装方針の判断材料になれば幸いです。

さいごに

ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com

【イベントレポート】「ZOZO.swift #2」を開催しました!

【イベントレポート】「ZOZO.swift #2」を開催しました!

はじめに

こんにちは、Developer Engagementブロックの@wirohaです。2月10日に「ZOZO.swift #2」をオンラインで開催しました。ZOZOのiOSエンジニアによるiOS特化のイベントです。昨年12月に第1回を開催しており、今回第2回目を開催できました。

イベントはオンライン開催でしたが、可能なメンバーはオフィスに集まって配信しました。その当日の雰囲気も含めてレポートします!

続きを読む

新卒が配属1か月目でClaude Codeと挑んだ、ZOZOTOWNデバッグ画面へのアーキテクチャ導入

新卒が配属1か月目でClaude Codeと挑んだ、ZOZOTOWNデバッグ画面へのアーキテクチャ導入

はじめに

こんにちは、2025年にiOSエンジニアとして新卒入社したZOZOTOWN開発1部iOSブロックのだーはまです。普段はZOZOTOWNのiOSアプリを開発しています。本記事では、新卒1年目の私がZOZOTOWNの画面へMVVM+UseCaseアーキテクチャを導入した過程と、工夫を紹介していきます。

目次

背景と目的

チーム配属から1か月でMVVM+UseCaseアーキテクチャ導入を担当した経緯

ZOZOTOWN iOSでは、MVVMとUseCaseで構成されるチーム標準のアーキテクチャがあります(以後アーキテクチャという表記はチーム標準のアーキテクチャを指します)。現在、アプリ全体の保守性と開発効率を向上させるため、チーム全体でこのアーキテクチャへの統一を進めるプロジェクトが進行しています

私自身、学生時代から設計への関心は高く、個人開発で同様の構成を取り入れた経験はありました。しかし、ZOZOTOWNのような歴史ある大規模なアプリの画面で、UIからビジネスロジックまでを含めて構成を見直した経験はありません。また、当時は入社して間もなく、経験したことのあるタスクはUIの修正やログ送信といった特定箇所の改修のみで、ZOZOTOWNの大規模なコードの全体像も把握できていない状態でした。

このような状況ではありましたが、iOSチームには以下のようにアーキテクチャ導入を支える手厚いフォロー体制1が整っていました。

  • アーキテクチャの指針書が整備されている
    • 各レイヤーの責務が言語化されている
    • レイヤーごとにコードを使った実装例がまとめられている
  • 設計段階でのレビューが義務化されており、実装後の手戻りを抑えられる

これらの環境に背中を押され、アーキテクチャ導入がチームへの貢献と自己成長へ繋がると考えました。そのため、チームへ配属されて1か月目というタイミングではありましたが、自ら手を挙げタスクオーナーとしてアーキテクチャ導入を進めることになりました。

取り組む目的は以下の通りです。

  • 技術的負債を抱えた画面のメンテナンスコストを下げること
    • 疎結合でテスタブルなコードにする
  • ZOZOTOWN iOSが採用しているアーキテクチャに慣れること
    • ZOZOTOWNのiOSチームが採用しているアーキテクチャへの理解を深め、他画面のコードリーディングを高速化する
    • テストコードの実装にも慣れるため、可能な限りテストを実装する
  • 大規模なコードを高速にキャッチアップするための練習をする
    • AIと協働して大規模なコードを高速にキャッチアップする方法を模索する

上記の目的を満たす画面をチーム内で議論し、社内向けでありユーザー影響のないデバッグ画面を選びました。

デバッグ画面について

デバッグ画面は、ZOZOTOWNのデバッグ用にデータを編集する画面です。開発している案件に応じてサーバーの向き先を切り替えるなど、開発する上で便利な機能が複数用意されています。デバッグ画面を使うことでQAや案件ごとに実施される動作確認をスムーズに行えます。このデバッグ画面はiOSエンジニアだけでなく、QAチームやバックエンドを始めとした社内の様々なチームおよびメンバーが触れるため、開発する上でとても重要な画面です。

しかし、新規案件の実装を優先しておこなっていく中で、社内向けでありプロダクトコードではないことから保守に手をつけることができずにいました。その結果、UIからビジネスロジックまでの実装が1つのViewControllerに記述され、機能を追加するたびにメンテナンスコストが増大していました。


課題

アーキテクチャ導入にあたり解決すべき課題は以下の2つです。

  1. コードやドメインに対する知識不足
  2. 700行以上に及ぶFatViewController

コードやドメインに対する知識不足

先述したようにデバッグ画面は依存関係が複雑です。

また、導入にあたり以下のような課題がありました。どの課題にも共通して「キャッチアップするべき項目が多く、十分にできていなかった(本人は十分にできているつもりだった)」ということが挙げられます。

  • 触れたことのないアーキテクチャの理解
    • 学生時代に個人開発で触れていたアーキテクチャ(MVVM,TCAなど)とは思想やルール、実装する上での責任が異なるため、チームが採用しているアーキテクチャを理解して慣れるまで時間がかかる
  • 既存実装の仕様を確認する難しさ
    • プロパティの初期値やデータソースへのアクセス、データを読み書きするタイミングなど、既存実装には多くの仕様が存在しており、それらすべてを確認して実装を進めるのが困難
  • テストコード実装時に発覚した「隠れた密結合」
    • 設計のレビューを通過し各レイヤーの責務を定義したはずが、いざ実装を進めるとデータソースの抽象化ができておらずユニットテストを書けない状態で手戻りが発生する

700行以上に及ぶFatViewController

既存の実装では1つのViewController内に画面の状態からデータソースへのアクセスまで全てのコードが記述されていました。責務分離がなされていないためクラスをDIできず、ユニットテストの記述が困難な状況です。また、StoryboardでUIを開発していたためメンテナンスコストが高くなっていました。実際にメンバーからは「デバッグ用のフラグを1つ追加したいだけなのに、Storyboardの修正や依存関係のあるコードの把握に時間がかかってしまう」という声が上がっていました。本来は開発を効率化するためのツールであるはずのデバッグ画面が、機能追加のたびに負債が溜まっていく画面になっていたのです。

MVVM+UseCaseアーキテクチャ導入前のデバッグ画面の依存関係


課題を解決するための取り組み

前述した課題を解消するため2点取り組みました。

  1. Claude CodeのSubagentsを使いキャッチアップを高速化
  2. MVVM+UseCaseアーキテクチャ導入

Claude CodeのSubagentsを使いキャッチアップを高速化

1つ目の課題で挙げたコードやドメインに対する知識不足を解決するため、コードの分析からプランニングまでをサポートするClaude CodeのSubagentsを3つ作成しました。Subagentsで分析することで、自力で読み解くよりも高速なドメイン知識の獲得が可能です。また、分析結果をもとにドキュメントを作成してもらうことで、レビューの説明文にも活かせます。

作成したSubagentsと役割は以下の通りです。

  • spec-refactorer
    • 既存コードを解析し、現状のロジックや仕様を整理
  • ios-architecture-engineer
    • アーキテクチャ導入に向けた技術的な調査と、依存関係の整理
  • refact-planner
    • 調査結果を元に、実装の優先順位や具体的な手順のプランニングを提案

3つのプロンプトを載せることは記事の都合上できないため、spec-refactorer のみを紹介します。spec-refactorer のプロンプトは以下の通りです。## 出力フォーマット に記載されているような画面の構成やデータフローなどをまとめます。

// 重要な部分のみ抜粋

## 目的

* 画面を開いてからの **データフロー**(依存解決→API→変換→State更新→描画)と、ユーザー操作(例: **Aというコンポーネントをタップ**)時の **処理・I/O・状態/遷移** を一目で把握できるようにする。


## 出力フォーマット

   1. TL;DR(初回ロードと主要アクションのI/Oを箇条書き)
   2. 画面の構成(UI/State/Lifecycleの表)
   3. データフロー(画面表示→初回ロードのシーケンス図)
   4. UIコンポーネント×アクション×副作用の対応表(Cell/ボタン/トグル/Pull-to-Refresh/ページネーション/セル内ボタン含む)
   5. 主アクション毎のシーケンス図(最低A=主ボタン, Refresh, セル選択)
   6. ネットワークI/O一覧(Method/Path/Auth/キャッシュ/失敗時/呼出根拠)
   7. 状態管理(公開State, アクション(必ず網羅すること。アクションを過不足なく網羅できるかがUXに直結する), 非同期/キャンセル)
   8. DI(依存解決の流れ)/ナビゲーション
   9. エラー/ローディング/空状態
   10. イベントログ送信箇所(ない場合は無しと記載)
   11. 分析イベント/フラグ
   12. リスク・改善
   13. Reference

## 探索範囲

   * **関連するファイルは可能な限り探索・調査** する(ViewModel/Reducer/Repository/API/Router/DI/テスト/拡張/ユーティリティを横断参照)。

## 網羅してほしい操作例(該当するもののみ)

   * 画面表示(初回ロード)
   * 主要ボタン(例: AddToCart/Favorite/Buy)タップ
   * セル選択(詳細遷移)
   * Pull-to-Refresh / ページネーション
   * 失敗時のエラーハンドリング

## 出力スタイル

   * 判断根拠となるコードは丁寧に過不足なく提示してください。
   * 技術構成を図に描く場合は、左側にUI層で右に行くほどDomain,Data層になるようにしてください。
   * 出力は日本語でお願いします。

以下の点が整理されるようにSubagentsを設計しました。これによってハルシネーションや考慮漏れを抑えた出力を得られるようになります。

  • 判断根拠となるコードをドキュメントの最後に記載させる
  • ユーザーアクションを軸にしてデータフローを整理させる
  • 見落としがちなポイントも確認させる
    • エラーやローディング時の挙動
    • ログ送信の有無
    • 外部へ公開しているプロパティ(画面の状態)は何か

MVVM+UseCaseアーキテクチャ導入

2つ目の課題で挙げられていた「Fat」な実装を解消するため、MVVM+UseCaseアーキテクチャを導入しました。MVVM+UseCaseアーキテクチャはAndroid Architecture Componentsを参考にしたアーキテクチャであり、以下のように役割を分担させます。

  • View / ViewController
    • UIレイアウトや画面遷移
  • ViewModel
    • イベントハンドリングやViewに最適化したデータ整形などのプレゼンテーションロジック
  • UseCase
    • キャッシュ管理などの特定のViewに依存しないビジネスロジックをカプセルし、原則として状態を持たない
  • Translator
    • DataSourceで取得したデータをUseCaseで扱える型へ変換することで、UseCaseにAPI等の知識が入り込むことを防ぐ
  • DataSource
    • データソース(APIやUserDefaultsなど)へアクセス

MVVM+UseCaseアーキテクチャ

ViewModel、UseCase、DataSourceはprotocolで抽象化しDIを可能にしました。これにより、UseCase、DataSourceのモックを作ることで、依存関係のあるクラスのテストを書けるようになります。

アーキテクチャ導入によりプレゼンテーションとドメインが疎結合となり、UI層のコードの変更が容易になったため、SwiftUIへの移行がスムーズに行えました。

結果

取り組みによって得られた結果は以下の通りです。

  • デバッグ画面のテストカバレッジ0→93.5%
  • UI実装をStoryboardからSwiftUIへ完全移行
  • 開発工数40%減少

デバッグ画面のテストカバレッジ0→93.5%

2つ目の課題で挙げたように既存実装は密結合が原因でテストを書けませんでしたが、ViewModel、UseCase、Translatorに対してユニットテストを書けるようになりました。

MVVM+UseCaseアーキテクチャ導入後のデバッグ画面のアーキテクチャ

カバレッジを計測するとデバッグ画面に関連するテストの平均が93.5%でした。残りの6.5%はテスト不要なコードが対象となっているため、実質100%となります。

カバレッジ計測結果

UI実装をStoryboardからSwiftUIへ完全移行

画面の実装をStoryboardからSwiftUIへ完全に移行させました。既存実装ではカスタムコンポーネントを使っていました。しかし、これらはOSのアップデートに伴うデザインシステムの変更や仕様変更の影響を受けやすく、その都度レイアウトの調整や挙動の修正が必要になるため、メンテナンスコストが増大していました。このメンテナンスコストを抑えるため、カスタムコンポーネントをSwiftUI化に合わせて廃止し、Apple標準のコンポーネントのみで画面を構成しました。

開発工数40%減少

既存実装に合わせたViewModelのリファクタリングや網羅的なユニットテスト実装など、Claude Codeに実装をサポートしてもらいました。

結果、Claude Code導入前に(先輩社員と相談し)見積もっていた工数35日が、Claude Codeを使うと20日で完了しました。約40%の工数削減となります。また、コードを書く時間が減り設計や実装方針を考える時間が増えたためClaude Codeなしに比べきれいなコードを書けました。

MVVM+UseCaseアーキテクチャ導入を振り返って

振り返ると、序盤は以下のような不安を抱えていました。

  • デバッグ画面のキャッチアップに時間がかかりすぎて、他画面のキャッチアップに時間を使えないのではないか
  • 既存仕様を確認できておらずリグレッションを発生させてしまうのではないか
  • (今後アサインされるであろう)他案件と折り合いがつかず中途半端に終わらせてしまうのではないか

冒頭でもお話ししたように、これらの不安の根本は「広い範囲のキャッチアップをうまく行えない」ことです。原因に序盤で気づき、Claude CodeのSubagentsを使ったキャッチアップ方法を模索するようになりました。初めは欲しい情報を得られずプロンプトを何度もチューニングしていましたが、今では開発に活きる情報(画面の状態やデータフローなど)が得られるようになっています。このように試行錯誤しながら作り上げたSubagentsのおかげでスピード感を持ってキャッチアップを進められました。また、Subagentsに調査結果をドキュメント化してもらうことで、ドキュメント生成のスピードも格段に上がりました。

これらの実績をまとめ、2025年10月には社内の全エンジニアを対象とした技術共有会で紹介しました。紹介をきっかけにiOSチーム以外の方にも活動を認知してもらい、「デバッグ画面を使いやすくなって作業が楽になりました!」のようなポジティブなフィードバックを多くいただきました。嬉しかったです。

アーキテクチャ導入を担当させてくれてサポートまでしてくれた上長やチームメンバーに感謝でいっぱいです。

最後に

本記事では、新卒1年目の私がAIを使ってデバッグ画面にアーキテクチャを導入した際の取り組みを紹介しました。現在は、その経験を活かして、より難易度の高い別画面への導入を進めています。試行錯誤の日々ですが、今回作成したSubagentsのおかげでコードリーディングの負担は格段に減り、開発スピードも上がったように感じています。本記事が、私と同じくキャッチアップの速度に課題を感じている方に届き、開発の生産性向上に少しでも貢献できれば幸いです。

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com


  1. アーキテクチャの設計およびレビュー体制は「ZOZOTOWN iOSアプリでのFatViewController解消への取り組み」をご覧ください

WEARアプリにおけるLiquid Glass対応への第一歩

WEARアプリにおけるLiquid Glass対応への第一歩

はじめに

こんにちは、WEARフロントエンド部iOSブロックの西山です。普段はWEAR iOSチームのマネジメント兼アプリの開発を担当しています。今年のWWDC25で、新しいソフトウェアデザインのLiquid Glassが発表されました。透明感のあるUIと流動的なアニメーションが特徴的なこの新デザインは、WEARアプリに大きな影響を与えました。鋭意進行中の取り組みとして、本記事では、Liquid Glass対応を計画的に進めるための取り組みを紹介します。

続きを読む

ZOZOマッチアプリのメッセージ機能を支えるFlutter × GraphQLの実装

ZOZOマッチアプリのメッセージ機能を支えるFlutter × GraphQLの実装

はじめに

こんにちは、新規事業部フロントエンドブロックの池田です。普段はZOZOマッチのアプリ開発を担当しています。2025年6月にマッチングアプリ「ZOZOマッチ」をリリースしました。ZOZOマッチにはメッセージ機能があり、この機能を実現するためにGraphQLを用いています。本記事ではFlutterアプリでGraphQLを用いたリアルタイムメッセージ機能の開発の知見と工夫した点をご紹介します。

なお、ZOZOマッチアプリ全体のアーキテクチャや技術構成については、別記事「ZOZOマッチアプリのアーキテクチャと技術構成」で詳しく紹介しています。

続きを読む

iOSDC Japan 2025協賛&参加レポート

iOSDC Japan 2025協賛&参加レポート

こんにちは、DevRelブロックのikkou(@ikkou)です。2025年9月19日の夕方から21日の3日間にわたり「iOSDC Japan 2025」が開催されました。ZOZOは昨年同様プラチナスポンサーとして協賛し、スポンサーブースを出展しました。

iOSDC Japan 2025 エントランス

technote.zozo.com

本記事では、前半は「iOSエンジニアの視点」から、ZOZOから登壇したセッションとiOSエンジニアが気になったセッションを紹介します。そして後半は「技術広報の視点」から、ZOZOの協賛ブースの様子と各社のブースコーデのまとめを写真多めでお伝えします。

登壇内容の紹介

今年のiOSDCではLTに2名、パンフレット寄稿に2名が採択されました。会場で発表されたLTについて紹介します。

iOSDC Japan 2025で登壇・寄稿したZOZOスタッフ

AlarmKitで実現する新時代のシステム通知(レギュラートーク)

「AlarmKitで実現する新時代のシステム通知」を発表中の續橋

續橋からのコメント:
iOS 26から使える最新のAPIということで、Track Dが満席になるぐらい多くの人に足を運んでいただき感謝です! ちょうどAlarmKitを使ってアプリを作ろうとしていた人や気になっていたけどキャッチアップしていなかったという人にもAlarmKitの素晴らしさと凄さを伝えられたのかなと思います! また、AlarmKitの人と認知してもらえて会場でも多くの人とコミュニケーションをとる事ができて捗りました。

今回は資料作りや登壇練習も万全とはいえず悔いの残るところもあったので、次も機会がいただけるならば、前もって資料作りを行い、計画性を持ってiOSDCに挑みたい所存です!

speakerdeck.com

全身画像からコーデアイテムを抽出し毎日にIRODORIを! デバイス完結型アプリを作る(ルーキーズLT)

「全身画像からコーデアイテムを抽出し毎日にIRODORIを!デバイス完結型アプリを作る」を発表中の濱田

濱田からのコメント:
コーデ選びの相棒を作るをコンセプトとしたアプリIRODORIにおける、AIモデルを使ったコーデアイテム抽出の話をしました! ZOZO社員ならではの”ファッション×テック”らしい良い発表ができたのではないだろうかと自画自賛しています。

最近はLLMの発展によってAIが広く使われるようになり、世の中の様々な問題がすごいスピードで解決されています。しかし、LTでもお話しした通り「AIは、便利な反面、金かかる」です。このコストの問題は開発者側に重くのしかかり、個人開発者やMVPで開発し効果検証をスピード感持って取り組みたい方にとってはかなりの障壁になるはずです。この問題を解決するためにLTで「AIをオンデバイスで動かす」という選択をお伝えしました。今回のLTが一人でも多くのエンジニアへ届き、世の中の問題解決に繋がれば幸いです!

最後に、LT登壇に向けてネタ出しから発表練習までサポートしてくれた社内のメンバーに感謝を伝えて締めようと思います。

speakerdeck.com

iOSエンジニアが気になったセッションの紹介

ZOZOのiOSエンジニアが気になったセッションをいくつか紹介します。

カスタムUIを作る覚悟

FAANS部でiOSエンジニアをしている、ましょー(@masho1017)です。まつじさんの「カスタムUIを作る覚悟」というセッションを拝聴しました。このセッションは、私にとって非常に学びの多い内容だったので、本記事でご紹介したいと思います。

まつじさんの発表では、カスタムUI開発の難しさについて、豊富な実例を交えながら解説されていました。具体的には「どのような場合にカスタムUIを作るべきか」「実際に作るとき意識すべき考え方」といった内容です。

本記事では、その中でも私が特に感銘を受けたポイントを2つ取り上げ、それらをFAANSでの開発にどのように活かせると考えたかを、私自身の視点からお伝えします。

1. 「対応しない/対応できていない」を明確に認識する

このセッションではカスタムUIだからといって、標準APIが備えるすべての機能(例:アクセシビリティ、アニメーションの細部)を必ずしも再現する必要はなく、重要なのは、「あえて対応しないのか」「現時点では対応できていないのか」を認識することだと述べていました。

FAANS iOSでは動画投稿機能(参考記事)の実装にあたり、デザインの観点から標準のUIImagePickerControllerではなくカスタムUIを採用しました。私自身がそのUIを実装したのですが、完成に満足してしまい、標準APIで担保されている挙動のうち、自作UIで不足している点を十分に把握できていませんでした。今後は、「どの要素を対応しないと判断したのか」「どの要素が未対応なのか」を設計段階から言語化することで、ユーザー体験を損なわないプロダクトを継続的に届けたいと思います。

2. カスタムUIに対して、UIデザイナー/エンジニアと線を引くのではなく同じクリエイターとして取り組む

カスタムUIでは、アニメーション(長さやアニメーションカーブ)、ダイナミックタイプ(レイアウト変更やアクセシビリティ対応)など、多くの考慮点があります。これらを「UIデザイナーが考えるべきか、それともエンジニアが考えるべきか」と分担を意識してしまいがちですが、このセッションでは両者が同じクリエイターとして取り組むべきだと強調されていました。

私はこの考え方に強く共感しました。iOS開発に限らず、幅広い分野のエンジニアにも通じる考え方だと感じます。

実際のFAANSチームでは、カスタムUIに関するアニメーションや細部の検討は基本的にUIデザイナーが行い、エンジニアはそれを実装する、という役割分担が定着しています。そのため、どうしても委ねがちになってしまう部分がありました。今後は、カスタムUIの設計・実装においてUIデザイナーと積極的に協業し、同じ目線で議論しながらプロダクトを作り上げる文化を育てていきたいと考えています。

以上が、私が感銘を受けたポイントと、それをFAANSでどのように活かしていくかについての考えです。セッションの最後には「カスタムUIは実装もメンテナンスも大変であり、終了は突然訪れる。すなわち、カスタムUIを作るには覚悟が必要だ」とまとめられていました。実際にまつじさん自身、iOS 18まで作り込んでいたカスタムUIが、iOS 26に追従できず破棄せざるを得なかった経験を共有されていました。それでも、仕様やデザインによってはカスタムUIを作らなければならない場面は必ずあると思います。そうした時こそ、このセッションで得た学びを活かし、UIデザイナーと協力しながら、ユーザー体験を損なわないカスタムUIを届けたいと思います。

iOSアプリのバックグラウンド制限を突破してバックグラウンド遷移後もアップロード処理を継続するまでの道のり

ZOZOTOWNでiOSエンジニアをしているつっきー(@tsuzuki817)です! 家族アルバム「みてね」のバックグラウンドアップロード技術に関するセッション「iOSアプリのバックグラウンド制限を突破してバックグラウンド遷移後もアップロード処理を継続するまでの道のり」を大変興味深く拝見しました!

複数の実装方法を試し、それぞれのメリット・デメリットを丁寧に比較されていたのが非常に分かりやすく、学びの多い内容でした。

中でも、Picture in Pictureを用いてバックグラウンドでのアップロードを継続させるというアイデアには「その手があったか!」と唸らされました。技術的な制約や課題がありながらも、「ユーザーの体験を絶対に止めない」という強い意志を感じ、開発者として大いに刺激を受けました。

ZOZOTOWNのサービスにおける直接的な応用シーンはまだ未知数ですが、複雑な課題に対して粘り強く最適解を探求する姿勢は、すべてのエンジニアにとって非常に参考になるはずです。まだご覧になっていない方は、ぜひ視聴をおすすめします!

***

FAANS部でiOSエンジニアをしている、イッセー(@15531b)です。私が特に印象に残ったセッションも續橋と同じ「iOSアプリのバックグラウンド制限を突破してバックグラウンド遷移後もアップロード処理を継続するまでの道のり」です。

私たちが開発しているFAANSには動画投稿機能があり、現状ではアプリを開いたままでなければアップロードを完了できません。家族アルバムアプリ「みてね」も同様の課題を抱えており、アプリを開いたまま写真や動画をアップロードする必要がありました。セッションでは、様々なバックグラウンド手法を検証した結果、最終的にPicture in Picture(PiP)を用いることでバックグラウンドでもアップロードを継続できるようになった経緯・実装方法が紹介されていました。

中でも興味深かったのは、バックグラウンド実行の多くの方法には制約がある一方で、PiPならそれを突破できたという点です。実装には難しさや制約があったものの、枠に囚われない発想でエンジニアリングの可能性を切り開いた事例として感銘を受けました。

また、iOS 26から追加されたBGContinuedProcessingTaskなどの複数のバックグラウンド実行の手法が検証されており、それぞれのメリット・デメリットが分かりやすく整理されていました。この技術的な比較は、FAANSにどの方法が適しているかを考える上で大変参考になりました。

今回のPiPによるバックグラウンドアップロードは特許出願中であり、そのまま活用することは難しいかもしれません。しかし、制約を乗り越えて機能を実現しようとするエンジニアの探究心に強く刺激を受けました。FAANSでも現在の制約を超え、バックグラウンドでのアップロードが可能となるよう検討していきたいと考えています。

『ホットペッパービューティー』のiOSアプリをUIKitからSwiftUIへ段階的に移行するためにやったこと

WEARフロントエンド部でiOSエンジニアをしているセータです! UIKitからSwiftUIにリプレイスする中で出てきた課題についての取り組みに関するセッション「『ホットペッパービューティー』のiOSアプリをUIKitからSwiftUIへ段階的に移行するためにやったこと」が個人的にかなり刺さる内容でしたので、ご紹介いたします。

現在、WEARでは一部画面でSwiftUIへのリプレイスを進めており、デザイナーとの意思疎通やリプレイスの方法などで模索している段階のため、チームにとっても非常に学びの多い内容でした。

まず、フェーズ分割という方法がとても印象的でした。いきなり画面単位のリプレイスをするのではなく、まずは画面を構成するUIコンポーネント単位からリプレイスを行います。WEARでは最初から画面単位でリプレイスを行っており、画面ごとで出てきたコンポーネントを実装するようにしています。そのため、1画面にかかる工数が肥大化し、実装者から、レビュアー、デザイナーまで全員の負荷が大きいと感じておりました。フェーズ分割をすることにより、UI実装の単位が最小化されることによる実装コストの削減、レビュアの負荷軽減、デザイナーとの連携強化が期待できるということでした。

具体的なポイントがさまざまありましたが、特に印象的だったものを2つご紹介します。

1つ目はスナップショットテストについてです。こちらでは、関心のあるスコープに閉じてテストできる点が強力だと感じました。スナップショットテストでは、同じ入力に対してアプリケーションの状態や出力が変化していないことを検証しますが、画面単位で検証をする場合、関心のある観点以外でテストが失敗してしまったり、1つのテストケースに対して複数の観点が必要になったりするなど、効率の悪くなる可能性があります。コンポーネント単位で検証をすることで、不要なパターンのテストもなくなり、無駄な実装を大幅に削減することができます。また、Previewsを利用してテストが可能なので、テストのためにわざわざ何かを作成しないといけないということもなく、効率的で導入しやすいと感じました。

2つ目はUIカタログアプリについてです。こちらでは、UIコンポーネントを集約したアプリをデザイナーに共有することで、デザイナーが実際に触って操作感などを確かめることができるという点が画期的であると感じました。現状のWEARでは、デザイナーによるレビューはスクショとFigmaの差分を確認したり、動画キャプチャで確認してもらったりしていますが、DeployGateを用いて自動で配信して手元で確認してもらうことが可能なので、コードによる実装とデザインの乖離、認識の違いなどをほぼ無くすことができると思います。また、スナップショットテストの自動生成がUIカタログの作成にも活用できるため、一貫してメリットを享受できる点がとても良かったです。

これらの仕組みを導入するのは、初期段階では時間がかかり、コストを要するものですが、将来的にチームメンバーが変わったり、細かいデザインが変わった際などに強力な効果を発揮したりするため、長期的にはとても効率的なものになると思います。WEARでもまだSwiftUIのリプレイスを少しずつ始めている段階ですので、デザイナーと協調し、効率的な開発体制を整えられるように今回の学びを活かしたいと思います。

スマートフォン 来し方行く末 〜どこから来てどこへ往くのか〜

iOSテックリードのらぷ(@laprasdrum)です。iOS 4(当時はiPhone OS・iPhone SDKと呼ばれていました)とAndroid 2.3からスマートフォンに触れ、開発者としてはiOS 4.3・Android 4.3からコードに携わってきました。今回のセッション「スマートフォン 来し方行く末 〜どこから来てどこへ往くのか〜」では、その頃の思い出を懐かしみつつ、PDAから現在のスマートフォンに至る約2時間の歴史を存分に楽しませていただきました。

セッションの中で特に印象に残ったポイントを1つに絞るのは難しく、すべてが見応えある内容でした。当時マウス操作の延長だったタッチUIの概念を変えたiPhoneのCocoa Touch、CPU・GPUアーキテクチャ史におけるPowerVRの立ち位置、GPSの進化、プッシュ通知の仕組みと通信コストの背景、Retinaディスプレイの登場。これでもまだ序盤のトピックですが挙げきれません。

ハードウェアの進化が人々の生活を変えていくのを見たり体験したりするのは本当に素晴らしいことです。同時に、限られたハードウェア仕様の中で工夫を凝らしてニーズを実現していくことには開発者としての喜びがあります。今回のセッションを聞いて、その両方の良さを改めて思い起こすことができました。

博識な@hakさんと@tomzohさんだからこそ、最後まで聞き飽きないセッションでした。ぜひ来年も楽しみにしています。

ZOZOブースの紹介

会期中はiOSエンジニアを中心として多数のZOZOスタッフが入れ替わりながらブースに立っていました。iOSDC Japan 2025では、DroidKaigi 2025と同じように、モニターでiOSエンジニア向けにまとめた技術スタックなどを紹介しつつ、昨年リリースした「ZOZOMAT for Kids」を体験できるコーナーを設けました。

目を惹くレッグトルソーは実際にZOZOMAT for Kidsの開発中に使用していたもの

ZOZOブースの様子。

ZOZOMATを説明しているスピーカーの續橋。

来場者と談笑している様子。

ZOZOでは毎年、デザイナーチームと共同で新しいノベルティを用意しています。DroidKaigi 2025に引き続き、iOSDC Japan 2025でも「シューズクリーナー消しゴム」をお渡しし、とても多くの方が手に取ってくれました。

今年のカンファレンス用ノベルティはシューズクリーナー消しゴム!

改めてiOSDC Japan 2025でZOZOブースに訪れていただいた皆様ありがとうございました!


iOSDC Japan 2025協賛企業のブースコーデまとめ

あっすー(@assu_ming)です。iOSDC Japan 2025の協賛企業ブースを回りながら、各社のファッションアイテムを撮影しました! これまでのイベントではTシャツを中心に紹介していましたが、今回は個性が光るおしゃれなアイテムに注目です。

株式会社MagicPodさん
MagicPodくんピアスとぬいぐるみ、実は社内エンジニアさんの愛が溢れる手作り。

サイボウズ株式会社さん
メッシュ巾着のサイボウサギンチャク。“底見せ映え”と実用性でUIもUXも素敵。

KINTOテクノロジーズ株式会社さん
くもびぃ+カチューシャ。それぞれの身につけ方で個性豊かなスタイリングに。

スパイダープラス株式会社さん
ブラックシャツ×アイボリーの王道コンビ。バッジでアレンジしていた方も素敵でした。

ディップ株式会社さん
オールブラックの装い。ブースのカラーと合わさって一層スタイリッシュです。

各社の遊び心あふれるアイテムから、楽しんでいらっしゃる雰囲気が伝わってきました。お忙しい中ご協力いただいたブースの皆さん、本当にありがとうございました!


Afterイベント

iOSDC Japan 2025開催翌月の10月1日から3日の3日間にかけて「extension DC 2025」が催されました。ZOZOからは、Day 3の「extension DC 2025 Day3 @ LINEヤフー」に森口と濱田の2名が登壇しました。

ZOZOTOWN開発2部の森口

森口からは「実装で解き明かす並行処理の歴史:Swift ConcurrencyからNSThreadまで遡ろう」と題して、コードを例示しながら歴史を紐解くセッションを行いました。詳しくはスライド資料をご覧ください。

speakerdeck.com

またパネルトークに森口と濱田の2名が参加しました。iOSDCの感想を振り返りつつ、エンジニア同士の意外なつながりも話され、笑いの多い楽しい時間となりました。

4名によるパネルトーク


おわりに

iOSDC Japanは10th Anniversaryを迎えた記念回でした。

ZOZOから参加した一部のメンバーで撮影した集合写真

iOSDC Japan 10th Anniversaryのスナックコーナー

ZOZOは毎年iOSDC Japanに協賛し、ブースを出展していますが、多くの方との交流を通して今年も最高の3日間を過ごせました。実行委員会の皆さんに感謝しつつ、来年もまた素敵な時間を過ごせることを楽しみにしています!

ZOZOでは、来年のiOSDC Japanを一緒に盛り上げるエンジニアを募集しています。ご興味のある方はこちらからご応募ください。

hrmos.co

また、会期中は混雑していることも多く、じっくりとお話しする時間が取れなかったので、もう少し詳しく話を聞きたい! という方はカジュアル面談も受け付けています。

hrmos.co

それではまた来年のiOSDC Japanでお会いしましょう! 現場からは以上です!

ZOZOMAT RendererにおけるOpenGL ESからMetalへの移行

ZOZOMAT RendererにおけるOpenGL ESからMetalへの移行

はじめに

こんにちは、ZOZO New Zealandの中岡です。普段はZOZOMAT/ZOZOGLASSの運用・保守や計測技術を使った新規事業の開発をしています。

目次

ZOZOMATとは

オンラインで靴を購入する際に、サイズが合わないという問題を解決する仕組みです。1台のスマートフォンと紙製のZOZOMATだけで、正確に足のサイズを測れます。足をスキャンすると、高精度の3Dモデルが生成されます。最適なサイズの靴も表示されるので、すぐに靴を購入できます。

zozomat

zozomat-renderer-view

ZOZOMATの構成

ZOZOMATの機能は社内ライブラリとして開発されており、ZOZOTOWNに組み込まれています。以下は依存関係の一部です。ZOZOMATの機能を提供しているライブラリはZOZOMATフレームワークと呼ばれており、フレームワークはさらに計測結果の3Dモデルの表示するためのZOZOMAT Rendererに依存しています。

本記事ではタイトルにもあるとおり、そのZOZOMAT RendererのOpenGL ESからMetalへの移行についてお話しします。

ZOZOMATの依存関係

移行の背景

足の3Dモデルのレンダリング使っているOpenGL ESはiOS12でDeprecatedになっており、将来的に利用できなくなる可能性がありAppleもMetalへの移行を推奨しています。

developer.apple.com

検討したアプローチ

ZOZOMAT Rendererは以下の図にあるようにクロスプラットフォームに対応しています。そのため、単純にプラットフォーム非依存レイヤーの中のOpenGL ESをMetalに書き換えることはできません。

現状のRendererの構成

techblog.zozo.com

移行するためには引き続きAndroidをサポートしつつiOSでのみMetalで動作するようにしなければいけません。そのためのアプローチは大きく分けて2つありました。

  1. bgfxのようなMetalをバックエンドとして利用可能なクロスプラットフォームのレンダリングライブラリに移行する
  2. バッファ作成や描画処理といったグラフィックスAPIを呼び出す処理を抽象化し、プラットフォームごとにOpenGL ES/Metalを呼び出す

最終的に、2番目のアプローチを選択しました。その理由は以下の通りです。

  • Android側の実装に極力影響を与えず、最小限の工数で進められる
  • 描画対象が比較的シンプルな3Dモデルであり、外部ライブラリの導入に見合うメリットが少なかった

移行後の構成

以下は移行後の簡単な構成図です。プラットフォーム非依存レイヤー(MVP行列の管理・シーン管理などのコアロジック)から、実際の描画呼び出し部分を切り出しました。そして、Cヘッダーで定義した抽象インタフェースを経由しOpenGL ES/Metalの各バックエンド実装に振り分けるといった構成です。

移行後の構成

レンダリングバックエンドの抽象化

グラフィックスAPIを使ったレンダリングには主に以下のようなステップがあり、Cヘッダーの抽象インタフェースはこれらの処理をするメソッドがステップごとに定義されています。

ステップ OpenGL ES Metal
1. シェーダーの読み込み - GLSL ソースをコンパイル・リンク
- プログラムオブジェクトを生成
- MSL ソースをライブラリ化
- MTLRenderPipelineStateを生成
2. バッファの作成 - VBO/EBO を生成してバインド - MTLBuffer を生成
3. 描画 - プログラムをアクティブ化してユニフォーム設定
- glDrawElementsを実行
- コマンドバッファ/エンコーダを作成
- 頂点/インデックスをエンコーダにセット
- drawIndexedPrimitives を実行

ポインタによる抽象化

MetalではバックエンドレイヤーでMTLBufferMTLRenderPipelineStateの生成をするためにMTLDeviceが必要です。また、各フレームでdrawIndexedPrimitivesを呼ぶ際にMTLRenderCommandEncoderも必要です。これらのオブジェクトはiOS側で生成しプラットフォーム非依存レイヤーを経由してバックエンドレイヤーに渡さなければいけませんでした。この際にMetal固有の型を隠蔽するためにポインタを使います。

以下は簡単なサンプルコードです。context_tにMetal固有の型を定義してその型のポインタをプラットフォーム非依存レイヤーを経由してバックエンドレイヤーに渡し型キャストして利用します。

// context.h
#import <Metal/Metal.h>
typedef struct context_t {
    id<MTLDevice> metalDevice;
    id<MTLRenderCommandEncoder> currentRenderCommandEncoder;
} context_t;
// ZMRMetalView.m
// プラットフォーム(iOS)側
#import "context.h"

@interface ZMRMetalView ()
{
    id<MTLCommandQueue> _commandQueue;
    context_t context;
    // 省略
}
@end

@implementation ZMRMetalView

// 省略

- (void) setup
{
    context.metalDevice = MTLCreateSystemDefaultDevice();
    _commandQueue = [_device newCommandQueue];
    // 省略

    // ここでcontextの参照をプラットフォーム非依存レイヤーに渡す
    zmrInit(&context);
}

// 毎フレーム呼ばれる
- (void) drawView:(id)sender
{
    id<MTLCommandBuffer> commandBuffer = [_commandQueue commandBuffer];
    MTLRenderPassDescriptor *renderPassDescriptor = [MTLRenderPassDescriptor renderPassDescriptor];
    // 省略

    // MTLRenderCommandEncoderの生成しcontextに渡す
    context.currentRenderCommandEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor];
}
// プラットフォーム非依存レイヤー
void zmrInit(void *context)
{
    // バックエンド側にcontextをそのまま渡す
    initBackend(context);
}
#import "context.h"

static context_t *metalContext
void initBackend(void *context)
{
    // 汎用ポインタからキャスト
    metalContext = (context_t *)context;
}

移行の際に当たった課題と工夫点

Objective-CとC/C++のメモリ管理の違い

Objective-CはARC(Automatic Reference Counting)を使用しており、C/C++は手動でメモリ管理します。今回の移行では、既存のクロスプラットフォーム設計を維持するため、MetalオブジェクトをCの構造体に保持する必要がありました。この際、ARCと手動メモリ管理の境界で適切なブリッジングをします。

CFBridgingを使ったリソース管理

MetalオブジェクトをCの構造体で管理する際の参照カウントの変化は以下です。

  1. 作成時(参照カウント+1): CFBridgingRetainでARC管理からC構造体の手動管理に移行

    • newBufferWithBytes:などでMetalオブジェクトを作成(参照カウント=1)
    • CFBridgingRetainで参照カウントを+1し、C側で保持(参照カウント=2)
    • ARC管理下のローカル変数がスコープを抜けると-1(参照カウント=1、C側のみが保持)
  2. 使用時(参照カウント変化なし): __bridgeで一時的にObjective-CオブジェクトとしてObjective-C++で参照

    • 参照カウントは変化せず、単にキャストのみ実行
  3. 破棄時(参照カウント-1): __bridge_transferで手動管理からARC管理に戻して自動解放

    • C側の所有権をARCに移譲(参照カウントは変化しない)
    • ARCがスコープ終了時に自動的に-1して解放(参照カウント=0)
// Cの構造体でリソースハンドルを管理
typedef struct {
    uint64_t vertexBufferHandle;
    uint64_t indexBufferHandle;
    // その他のメンバー...
} RenderResource;

// Metalリソースの作成
void setupRenderingResources(RenderResource *resource)
{
    // Metalバッファを作成(ARCで管理)
    id<MTLBuffer> vertexBuffer = [device newBufferWithBytes:vertices 
                                                     length:vertexDataSize 
                                                    options:MTLResourceStorageModeShared];
    
    // CFBridgingRetainでCの構造体にリソースを保存
    resource->vertexBufferHandle = (uint64_t)CFBridgingRetain(vertexBuffer);
}

// Objective-C++側でMetalリソースを使用
void drawFrame(RenderResource *resource)
{
    // __bridgeでハンドルをMetalオブジェクトに戻す(所有権は移さない)
    id<MTLBuffer> buffer = (__bridge id<MTLBuffer>)(void *)resource->vertexBufferHandle;
    
    [currentEncoder setVertexBuffer:buffer offset:0 atIndex:0];
    // 描画処理...
}

// リソースのクリーンアップ
void cleanupRenderingResources(RenderResource *resource)
{
    // __bridge_transferで手動管理からARCに所有権を戻す
    id<MTLBuffer> buffer = (__bridge_transfer id<MTLBuffer>)(void *)resource->vertexBufferHandle;
    // bufferはここでスコープを抜けてARCによって自動的に解放される
    resource->vertexBufferHandle = 0;
}

座標系の違い

OpenGLESとMetalではNDC(正規化デバイス座標)のZ座標の範囲が異なるため、同じ投影行列を使用する場合は注意が必要です。もともとOpenGLESの座標系に従った行列が渡されるため、Metalでは以下のように頂点シェーダーでZ軸の変換をする処理を加えました。

GLSL

// 頂点シェーダー(GLSL)
layout (location = 0) in vec3 position;

uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;

void main() {
    gl_Position = projection * view * model * vec4(position, 1.0);
    // OpenGLはそのままNDC座標を使用
}

MSL

// 頂点シェーダー(MSL)
vertex float4 foot_vertex(float3 position [[attribute(0)]],
                         constant float4x4 &view [[buffer(1)]],
                         constant float4x4 &projection [[buffer(2)]],
                         constant float4x4 &model [[buffer(3)]])
{
    float4 pos = float4(position, 1.0);
    float4 clipPos = projection * view * model * pos;
    
    // OpenGLのNDC Z座標 [-1,1] をMetalの [0,1] に変換
    float newZ = (clipPos.z * 0.5) + 0.5;
    return float4(clipPos.xy, newZ, clipPos.w);
}

まとめ

本記事ではZOZOMAT RendererのOpenGL ESからMetalへの移行について、既存のクロスプラットフォーム設計を維持するための抽象化アプローチやその際の注意点を解説しました。

また現在の実装では、GLSLとMSLのシェーダーが二重管理となっています。そのため、将来的にはSPIRV-Crossのようなシェーダー変換ツールの導入を検討しています。SPIRV-Crossを使用することで、単一のシェーダーソースからOpenGL(GLSL)とMetal(MSL)両方のシェーダーを自動生成できるようになり、シェーダーの一元管理が可能になります。

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com

カテゴリー