CompositionalLayoutを用いた横スクロールのレイアウト改修で直面した問題と解決方法

こんにちは。WEAR部iOSチームの小野寺です。

先日CollectionViewで実装しているトップページを改修しました。

改修はトップページに並べていたコンテンツを1つにまとめて、横スクロール(手動 / 自動)によってコンテンツを切り替え可能にしました。

CompositionalLayoutで横スクロール

横スクロールによってコンテンツを切り替える仕様なので、CompositionalLayoutで実装しました。

上記の方針で進めていく中で、困難な実装に直面したので紹介します。

セクション全体への装飾

最初に直面した問題が、セクション全体に対するViewの装飾です。今回画像の赤枠部分について、次のように改修が必要になりました。

トップページ改修の要件

  1. 横スクロールで、コンテンツを切り替えられるレイアウトに変更
  2. その上に固定で表示され続けるViewを被せる(画像の赤枠)
  3. 固定で表示するViewの高さはタグ名の長さによって変わる
改修前 改修後
トップページ改修前 DecorationViewの固定表示

レイアウト調整にコストがかかる

固定で表示され続けるViewは、UICollectionReusableView, NSCollectionLayoutBoundarySupplementaryItemを使用しました。

func createLayout() -> UICollectionViewCompositionalLayout {
~~ 省略:横スクロール用のレイアウト設定 ~~

let decorationViewHeight = 150

let decorationViewSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(decorationViewHeight))
let decorationView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: tagContainerViewSize,
                                                                elementKind: “tag-container-element-kind”,
                                                                alignment: .bottom,
                                                                absoluteOffset: .init(x: 0, y: -decorationViewHeight))
section.boundarySupplementaryItems = [decorationView]

~~ 省略 ~~
}

追加したViewのy座標を、セクションの乗せたい位置(decorationViewHeight)までずらすことで、セクション全体へかかるようにしています。

ここではまだ、高さが変わることを考慮していないので、内容によってはレイアウトが崩れてしまいます。

CompositionalLayoutのフッターViewのレイアウトの差分

Viewの高さを計算する対応を追加し、コンテンツの表示に必要な高さを確保します。

class DecorationView: UICollectionReusableView {
~~ 省略 ~~
    
    static func calcViewHeight(title: String, containerSizeOfWidth: CGFloat) -> CGFloat {
        let decorationView = calculationBaseView
        decorationView.label.text = title
        decorationView.setNeedsDisplay()
        decorationView.layoutIfNeeded()
        let layoutViewSize = layoutView.systemLayoutSizeFitting(CGSize(width: containerSizeOfWidth, height: 0),
                                                                withHorizontalFittingPriority: .required,
                                                                verticalFittingPriority: .fittingSizeLevel)
        return layoutViewSize.height
    }
    
~~ 省略 ~~    
}

追加したcalcViewHeight()をdecorationViewHeightへ反映させます。

func createLayout() -> UICollectionViewCompositionalLayout {
~~ 省略:横スクロール用のレイアウト設定 ~~

// let decorationViewHeight = 150
let title = "冬がはじまるよ"
let layout = collectionView.layoutAttributesForItem(at: IndexPath(item: 0, section: 0))!
let decorationViewHeight = DecorationView.calcViewHeight(title: title, containerSizeOfWidth: layout.frame.width)

let decorationViewSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(decorationViewHeight))
let decorationView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: tagContainerViewSize,
                                                                elementKind: “tag-container-element-kind”,
                                                                alignment: .bottom,
                                                                absoluteOffset: .init(x: 0, y: -decorationViewHeight))
section.boundarySupplementaryItems = [decorationView]

~~ 省略 ~~
}

コンテンツの内容によって高さを取得する必要があるのでレイアウト調整にコストがかかっています。

OSによってはフッターが非表示になる

さらに、iOS14.5未満のOSバージョンで、フッターがセルの裏に隠れてしまう問題もありました。

iOS14.5未満でのフッターViewのレイヤー

この事象に対しては、UICollectionReusableViewを使った対応が取れませんでした。

今回は後にターゲットOSを上げる予定があった為、暫定対応としてUICollectionReusableViewに直接実装したコンテンツをカスタムUIViewとして切り出しました。

事象が発生するバージョンではUICollectionReusableViewは使用せず、このカスタムUIViewをCollectionViewのSubViewとして扱う改修で対応しました。

カスタムUIViewとして扱う

コンテンツの自動スクロール

レイアウトの改修の次は、自動でコンテンツをスクロールさせる機能の追加です。

自動スクロール機能の要件

  1. 一定時間に画面の操作がない場合に、次のセルを表示
  2. 最後のセルまで表示させたら、先頭に戻る

carouselの自動スクロール成功例

自動スクロール中に意図しないスクロールの発生

自動スクロールの対応は、はじめに以下の方針で検討しました。

  1. 一定時間でスクロールできるように、Timerを追加して定周期でスクロール処理を呼び出す。
  2. スクロール処理は、scrollToItem(at:at:animated:)をデフォルトのアニメーションを有効にして、コンテンツをスクロールさせる。

Timer側からの呼び出し

Timer.scheduledTimer(
    withTimeInterval: 3.0,
    repeats: true,
    block: { [weak self] _ in
        guard let self = self else { return }
        let toItem = displayContentIndexPathItem + 1
        
        self.scrollToItemForCarousel(at: .init(item: toItem, section: 0))
    }
)

定周期で呼び出すスクロール処理

func scrollToItemForCarousel(at indexPath: IndexPath) {
    collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
}

実際動作させてみたところcollectionView.contentOffset.yが、アニメーションの度に改修したセクションのデフォルトの位置へと、引き戻される問題が発生しました。

carouselの自動スクロール失敗例

原因は、scrollToItem(at:at:animated:) へ第1引数で渡しているindexPathにありました。スクロール先の指定にindexPathが使用されることで、x座標、y座標それぞれに対してscrollToItemが作用してしまいます。

これによりCollectionViewの垂直方向が、少しでもスクロールされた状況下の場合に、事象が発生してしまいました。

自作の自動スクロールのアニメーションを追加

問題を解消するために、今回はscrollToItem(at:at:animated:)のデフォルトのアニメーションは使わず、次のような自作アニメーションを実装しました。

  1. 自動スクロール前のcontentOffsetの位置を保持する
  2. scrollToItem(at:at:animated:)でスクロール位置を更新
  3. y座標を調整前の値で更新する
  4. 2と3を1つのアニメーションとして扱う
func scrollToItemForCarousel(at indexPath: IndexPath) {
    let currentOffset = collectionView.contentOffset
    
    UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) { [weak self] in
        guard let collectionView = self?.collectionView else { return }
        
        collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
        collectionView.contentOffset.y = currentOffset.y
    }
}

scrollToItemでのindexPathの更新とy座標をもとに戻す対応を1つのアニメーションとして実装することで、引き戻される問題を回避しています。

先頭へ戻るアニメーションにコストがかかる

ここまでの対応で、自動で次のコンテンツへ切り替える対応ができました。次に最後のセルまで表示させたら、先頭に戻るアニメーションが必要です。

戻る処理についてもscrollToItem(at:at:animated:)のアニメーションをそのまま使用できれば、簡潔に対応可能です。

(第1引数へ先頭のIndexPathを指定することで、コンテンツが最終位置にいる状態から、アニメーション付きで一気に先頭へ戻す挙動を実現できます)

しかし自作アニメーションを使用する場合は、次のような変更が必要になりました。

  1. UIView.animateのcompletion内で再度自作アニメーションを呼ぶ処理を追加
  2. 引数needsRepeateを追加し、先頭のセルに戻ってくるまでこの値を有効にする
  3. needsRepeateの値が有効の間、自作アニメーションを繰り返す

アニメーション処理を繰り返すことで、最後尾から先頭までのコンテンツの移動を、1つのアニメーションのように見せます。

この際durationの値を調整して、scrollToItem(at:at:animated:)のアニメーションを使った場合の挙動に近づけています。

func scrollToItemForCarousel(at indexPath: IndexPath, needsRepeate: Bool) {
    let currentOffset = collectionView.contentOffse
    let duration: TimeInterval = needsRepeate ? 0.05 : 0.3

    UIView.animate(withDuration: duration, delay: 0, options: .curveEaseInOut) { [weak self] in
        guard let collectionView = self?.collectionView else { return }

        collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
        collectionView.contentOffset.y = currentOffset.y
    } completion: { [weak self] _ in
        let previousIndex = indexPath.item - 1
        
        if needsRepeate {
            self?.scrollToItemForCarousel(at: .init(item: previousIndex, section: indexPath.section),
                                          needsRepeate: previousIndex >= 0)
        }
    }
}

CompostionalLayoutを使った実装は、レイアウトを組むまでは容易でしたが「セクション全体への装飾」と「コンテンツの自動スクロール」の実装が複雑になり大変でした。

CompositionalLayoutを使わない解決策

これらの実装をCompostionalLayoutを使わず、CollectionViewを中に入れたセル(ContentCollectionViewInCell)を使う方法で考えてみます。

CellにCollectionViewを載せる1

ContentCollectionViewInCellは、横スクロールで切り替え可能なコンテンツの表示に使用するCollectionViewと装飾に使用するViewを配置したもので考えてみます。

ContentCollectionViewInCell.xib CellにCollectionViewを載せる2

ContentCollectionViewInCell.swift

class ContentCollectionViewInCell: UICollectionViewCell, UICollectionViewDataSource {
    @IBOutlet var collectionView: UICollectionView!
    @IBOutlet var decorationView: DecorationView!
    var contentImages: [UIImage] = []
    
    override func awakeFromNib() {
        super.awakeFromNib()
        configureCollectionView()
    }
    
    func configure(contentImages: [UIImage], labelText: String) {
        self.contentImages = contentImages
        
        collectionView.reloadData()
    }
    
    private func configureCollectionView() {
        collectionView.register(ContentImageCell.self, forCellWithReuseIdentifier: ContentImageCell.identifier)
        collectionView.dataSource = self
        collectionView.collectionViewLayout = createPagingContentLayout()
    }
    
    private func createPagingContentLayout() -> UICollectionViewLayout {
        UICollectionViewCompositionalLayout { _, _ -> NSCollectionLayoutSection? in
        
            ~~ 省略:横スクロール用のレイアウト設定 ~~
            
            return section
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return contentImages.count
    }
    
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ContentImageCell.identifier, for: indexPath)
        
        ~~ 省略:cellの中身の更新 ~~
        
        return cell
    }
}

セクション全体を装飾するViewの調整

ContentCollectionViewInCellを使用した場合、CompostionalLayoutで苦労したセクション全体を装飾するViewの高さの調整は、オートレイアウトで解決できます。

実際に「ComposionalLayoutを使用した場合」と「ContentCollectionViewInCellを使用した場合」で実装を比較してみます。

ComposionalLayoutを使用した場合
class ViewController: UIViewController {
    ~~ 省略 ~~
    
    func createLayout() -> UICollectionViewCompositionalLayout {
        ~~ 省略:横スクロール用のレイアウト設定 ~~

        let title = "冬がはじまるよ\nアウターはMAST\n見れたらいいねスターダスト"
        let layout = collectionView.layoutAttributesForItem(at: IndexPath(item: 0, section: 0))!
        // 最前面に配置したViewの高さを計算
        let decorationViewHeight = DecorationView.calcViewHeight(title: title, containerSizeOfWidth: layout.frame.width)

        // 最前面に配置したViewの高さの反映と位置を調整する
        let decorationViewSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(decorationViewHeight))
        let decorationView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: tagContainerViewSize,
                                                                        elementKind: “tag-container-element-kind”,
                                                                        alignment: .bottom,
                                                                        absoluteOffset: .init(x: 0, y: -decorationViewHeight))
        section.boundarySupplementaryItems = [decorationView]

        return section
    }
    
    ~~ 省略 ~~
}
ContentCollectionViewInCellを使用した場合
class ContentCollectionViewInCell: UICollectionViewCell, UICollectionViewDataSource, UICollectionViewDelegate {
    ~~ 省略 ~~
    
    func updateDecorationViewLabel() {
        decorationView.contentLabel.text = "冬がはじまるよ\nアウターはMAST\n見れたらいいねスターダスト"
        collectionView.reloadData()
    }
}

ContentCollectionViewInCellを使用した場合、decorationViewが持つラベルの設定とcollectionViewの更新のみで、期待した高さが反映されます。

OS差分の問題

ComposionalLayoutを使用せずにContentCollectionViewInCellを使用した場合は、iOS14.5未満とそうでないOSの差分による影響も無くなります。

コンテンツの自動スクロール機能

コンテンツの自動スクロール機能もContentCollectionViewInCellを使用した場合は、実装が容易になります。

y座標は常に固定で扱うことが可能なことから、scrollToItem(at:at:animated:)の呼び出しのみで対応することが可能になります。

実際に「ComposionalLayoutを使用した場合」と「ContentCollectionViewInCellを使用した場合」で比較すると次のようになります。

ComposionalLayoutを使用した場合
class ViewController: UIViewController {
    ~~ 省略 ~~

    func scrollToItemForCarousel(at indexPath: IndexPath, needsRepeate: Bool) {
        let currentOffset = collectionView.contentOffse
        let duration: TimeInterval = needsRepeate ? 0.05 : 0.3

        UIView.animate(withDuration: duration, delay: 0, options: .curveEaseInOut) { [weak self] in
            guard let collectionView = self?.collectionView else { return }

            // 次のセルに進めるアニメーション
            collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
            collectionView.contentOffset.y = currentOffset.y
        } completion: { [weak self] _ in
            let previousIndex = indexPath.item - 1
        
            if needsRepeate {
                // 先頭のセルに戻すアニメーション
                self?.scrollToItemForCarousel(at: .init(item: previousIndex, section: indexPath.section),
                                              needsRepeate: previousIndex >= 0)
            }
        }
    }
    
    ~~ 省略 ~~
}
ContentCollectionViewInCellを使用した場合
class ContentCollectionViewInCell: UICollectionViewCell, UICollectionViewDataSource {
    ~~ 省略 ~~

    func autoScroll(toIndex: Int) {
        let totalContents = 5

        if indexPath.item < totalContents {
            // 次のセルに進めるアニメーション
            collectionView.scrollToItem(at: .init(item: toIndex, section: 0), at: .centeredHorizontally, animated: true)
        } else {
            // 先頭のセルに戻すアニメーション
            collectionView.scrollToItem(at: .init(item: 0, section: 0), at: .centeredHorizontally, animated: true)
        }
    }
    
    ~~ 省略 ~~
}

このように今回紹介したケース単体で見ると、CollectionViewを中に入れたセルを使う対応の方がより簡潔に済みます。

CollectionViewInCellの問題点

CollectionViewInCellにも仕様によっては、かえってコード量の増加や実装の複雑度が上がってしまう問題が考えられます。

考えられる問題

  1. セル毎にCollectionViewの設定が必要になる
  2. 親のセルが表示するセルの状態の管理をするようになる
  3. セルの階層に比例してタップアクションのハンドリングが複雑になる

実現したいUIに対する実装が容易になる一方で、アクションに対する実装やその後の運用が困難になるといったトレードオフがあることを理解しておく必要があります。

おわりに

今回は複数のレイアウトが混在する画面の改修だったため、CompositionalLayoutを使う方針を選択しました。

これにより、横スクロールのような複雑なレイアウトに対しても簡潔に取り入れることができました。

その一方で、凝ったUIの仕様では実装の複雑度が上がってしまいました。

今回は最初に検討していた、CompositionalLayoutで実現する方法を選択しましたが、メリット・デメリットを把握して現状取れる最適な選択をすることが大事だと感じました。

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

hrmos.co

ハイブリッド開催に完全対応! iOSDC Japan 2022参加レポート

OGP

はじめに

こんにちは、ZOZOTOWN開発本部の松井とZOZO NEXTの木下です。9/10から9/12までの3日間、iOSDC Japan 2022が開催されました。ZOZOグループからは6名が登壇、20名以上が参加しました。またプラチナスポンサーとして協賛しました。

technote.zozo.com

今年のiOSDCは時代に即した形で、現地会場とオンライン配信によるハイブリッド開催でした。今回は、その両方を盛り上げるために行ったZOZOの取り組みをご紹介いたします。

現地ブース

準備

5月上旬にスポンサー募集が開始され、社内でブース出展の是非を話し合いました。3年ぶりの現地会場であり、多くの来場者と活発にコミュニケーションをとれることを期待して、ブース出展を決定しました。スポンサーのノベルティや掲載物については、iOSエンジニアと広報・CTOブロックが連携して進めました。

展示内容

ブースについてはどういった展示内容にするかも含めて、エンジニアが主体的に進めていくこととなりました。最初にMiroでマインドマップを作成し、ブレインストーミングを行いました。久々のブース出展ということで、前回出展した2019年の様子を振り返りながらアイデアを深めました。

アイデア出しのマインドマップ

その後展示内容ごとに担当者を決め、2週間程度で準備をしました。実際に展示したのは以下です。

  • アンケートやプロダクト紹介が載ったMiroのボード(次の章で詳しく説明します)
  • ZOZO独自の計測テクノロジー「ZOZOSUIT」や「ZOZOMAT」・「ZOZOGLASS」、ZOZOSUITの技術を活用した新事業「ZOZOFIT」
  • 弊社のカルチャーや開発環境の紹介

当日の様子

そして当日できあがったブースがこちらです! ブースに来てくださった方へのノベルティや、ZOZOSUITの技術を活用した新事業、ボディマネジメントサービス「ZOZOFIT」のパッケージと専用アプリ画面の展示です。

現地ブースの様子

トルソーに着せたZOZOSUITの存在感があり、会場から多くの視線を集めていました。

ZOZOSUITの展示

足の3D計測マット「ZOZOMAT」やフェイスカラー計測ツール「ZOZOGLASS」など、現在ZOZOTOWN内でご利用いただける計測テクノロジーもご紹介しました。

様々な計測テクノロジーの展示

ブースではまずアンケートに答えていただき、そこからリモートワークに関する話に花が咲きました。また、ZOZOSUITについて興味を持ってくださった方には、旧ZOZOSUITからの改良点や今後の展望についてお話ししました。

会場全体はこのような雰囲気で、個性を生かしたブースが並んでいました。全てのブースを拝見しましたが、技術的なお話をうかがえたりデモアプリを実際に触れられたりなど、どのブースもとても魅力的でした。

会場全体の雰囲気

ブース出展を通して、対面だからこそできる気軽なコミュニケーションのありがたみを改めて感じました。様々な方と、技術や会社についてお話ししたり、「Twitterでよく見ております!」という会話が生まれたりしました。ブースへ遊びに来てくださった方々、ありがとうございました。

オンラインブース

今年のiOSDCはオフラインとオンラインのハイブリッド開催でしたね。現地に来られない方もZOZOのブースを楽しめるようにと、今年はZOZO独自にオンラインブースも用意していました! iOSDCチャレンジで使われるトークンは、実はオンラインブースにも隠されていたんです。チャレンジした方、見つけられましたか?

準備

ZOZOでは、テックカンファレンスでのオンラインブースの用意は初めてでしたが、Miroを使って全体のデザインからコンテンツの細部までエンジニアがメインで作り上げました。

オンラインブース「ZOZOの広場」

このオンラインブースはコースを進むようにして順番にコンテンツを見ていけるようなデザインにしています。また、リンクを開いた時にまずはなにをしたら良いのか、箱猫マックス(ZOZOの公式キャラクター)が教えてくれるようファーストビューを設定しました。このようにして、参加してくださったお客様が迷わない工夫をしています。

こだわったファーストビュー

そして、オンラインブースでもできる限り現地ブースと同じような体験を作りたい! という思いがありました。そこで、オンライン上でもZOZOのiOSエンジニアに気軽に話を聞きにいけるスペースを用意しました。

当日の様子

オンラインブースのURLはTwitterやZOZO DEVELOPERS BLOG、現地ブースでの2次元コードで発信していました。オンラインブースには多くの人に来場いただき、アンケートは大盛り上がり! たくさんのご回答、ありがとうございました!

アンケートの様子

また、Google Meetにもトークの合間を縫って話に来ていただきました。

ZOZOには「全国在宅勤務制度」があり、日本国内であればどこでも就業可能なため、現地への参加のしやすさは人それぞれです。オンライン参加でもトークを聞いたりiOSDCチャレンジに挑戦したりするだけでなく、Google Meetにて待機することで、イベントの臨場感を味わえました。また、オンライン参加のお客様に対しても現地ブースを擬似体験しているような機会を提供でき、双方にとってよい取り組みとなったように感じます。来年も引き続きオンラインでの開催が行われるようなので、さらに盛り上げていきたいですね!

登壇内容の紹介

今年ZOZOからは6名が登壇しました。昨年に引き続き、会社としての一体感を出すため、登壇者一覧のスライドを社内のデザイナーの方にお願いして作成し、発表で利用しました。

登壇者一覧のスライド

それぞれの登壇内容についてご紹介します。

「PWAの今とこれから、iOSでの対応状況」

木下のレギュラートークです。 登壇時の様子画像提供:iOSDC Japan 2022実行委員会。

ウェブアプリに、ネイティブアプリに近い機能を付加した、Progressive Web Apps (PWA)について発表しました。PWAの概要や歴史から始まり、iOSにおける対応状況やPWAを特徴づける機能、さらにPWAを採用する判断の助けとなるフローチャートなどをまとめました。

ハイブリッド開催ということで直接もしくはTwitter上などで、「PWAって結構色々できそう」という声や、「WebViewだけのアプリを作るならPWA良さそう」など色々と反応いただきました。とても嬉しかったので、また登壇できるように頑張ります。

fortee.jp

「20分間で振り返るIn-App Purchaseの歴史」

@inokinnのレギュラートークです。 登壇時の様子

13年以上の長きに渡る、In-App Purchaseの機能の歴史について発表させていただきました。現地会場やTwitterでは、感想や質問をいくつかいただくことができて非常に嬉しかったです。来年も現地参加するつもりなので、またお会いしましょう!

fortee.jp

「あなたの知らないARの可能性を空間レベルで拡げるVPSの世界」

HEAVEN chan / ikkouのレギュラートークです。 登壇時の様子

Hi, I’m HEAVEN chan! 去年、一昨年はWebARについて喋りましたが、今年はVPSについて喋りました! 今はまだメインストリームの技術ではないものの、今回の発表をきっかけにして少しでも興味を持つ方が増えたら嬉しいです! ついにiOS 16もリリースされたので張り切ってやっていきましょう!

fortee.jp

「Swift Concurrency時代のリアクティブプログラミングの基礎理解」

ばんじゅんのレギュラートークです。 登壇時の様子画像提供:iOSDC Japan 2022実行委員会。

基礎シリーズということで、すぐに使えるとか役立つというものではなかったのですが、みなさん聴いてくれてありがとうございました。Swift Concurrencyとリアクティブプログラミングの例に限らず、たまには基礎に戻って理解を固めておくモチベーションに繋がれば良いなと思っています。並行計算というのはそもそも難しいものなので、今後も着実にやっていきましょう。

fortee.jp

「全力疾走中でも使えるストップウォッチアプリを作る」

Ogijunのレギュラートークです。 登壇時の様子画像提供:iOSDC Japan 2022実行委員会。

みなさんが日常で使っているストップウォッチアプリの使いやすさを陸上競技の選手という特殊な目線で評価し、どんなインタラクションが最適であるかを検証しました。実際に自分が走りながら使った際の動画を見せることで、現状の不便さや作成したアプリの強みを皆様に伝えられたかと思います。他のセッションに比べて異質なテーマでしたが、実際にアプリをDLしてくださったり、フィードバックをいただけてとても嬉しく思います。また来年も登壇できるようこれからも頑張ります!

fortee.jp

「目からビームでヴィランをやっつける 〜ARKitの知られざる並走機能〜」

ながいんのレギュラートークです。 登壇時の様子画像提供:iOSDC Japan 2022実行委員会。

ARKitの一機能である、ワールドトラッキングとフェイストラッキングの並走機能にフォーカスしたトークで発表しました! 初登壇だったのですが、現地・オンラインから「おもしろい!」というリアクションをいただいて、今後のモチベーションに繋がりました。来年はトークだけでなく原稿にも挑戦したいです!

fortee.jp

CfPネタ出し会 & レビュー会

弊社では毎年、自由参加でiOSDCのCfPネタ出し会 & レビュー会をiOSエンジニア同士で行っています。今年も例年通り開催しました。詳細については昨年のブログで紹介しています。

techblog.zozo.com

今年は昨年と比べてプロポーザルの募集期間が短かったため、次のようなスケジュールで進めました。

5/12にCfPの募集が開始され、5/25に2時間程度のネタ出し&レビュー会を開催しました。会ではドキュメントに予めネタを書き出しておき、相互にレビューしました。またレビューを進める中で新たにネタを思いついた場合には書き足していくというのを行い、最終的に約25件のネタが集まりました。

6/1に技術顧問である岸川さんより、CfPを読む人にトークの期待値が伝わりやすくなっているかなど様々な観点から最終レビューをいただきました。

結果的に6件のトークが採択されました。ネタ出し&レビュー会はここ数年で定着すると共に、書き方に関するノウハウもたまってきています。来年以降も引き続き実施していこうと考えております。

また弊社ではカンファレンスへの参加は業務として扱われるため、iOSDCには休日出勤という形で参加しました。今年も内定者アルバイトの方が複数名イベントへ参加しましたが、社員と同様にチケット代は経費となり、イベントへの参加は業務時間として扱われています。

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

hrmos.co

hrmos.co

ZOZOTOWNホーム画面におけるログ設計と改善サイクルの紹介

データドリブンなトップ面

はじめに

こんにちは、ML・データ部推薦基盤ブロックの宮本(@tm73rst)です。普段は主にZOZOTOWNのホーム画面や商品ページにおいて、データ活用やレコメンド改善のプロダクトマネジメントを行っております。

近年ビックデータ社会と言われる中、データドリブンという言葉をよく耳にします。ZOZOTOWNのホーム画面は、ホーム画面の各パーツごとにViewable Impression(以降、view-impと表記)を取得できるようになったことでデータドリブンな評価や意思決定が促進されました。

本記事では特にZOZO独自のview-impの設計とview-impを用いてどのようにホーム画面を改善しているかについて紹介します。データドリブンな施策の推進を検討している方に向けて、本記事が参考になれば幸いです。

本記事におけるViewable Impressionの定義

本記事ではホーム画面のview-impの定義を「ホーム画面の各パーツに対して、ユーザーが実際に目に触れて認識した閲覧ログ」とします。そのため、パーツの内容を把握できない瞬間的な表示や部分的な表示はview-impの発火と見なさず、表示時間や表示面積などの発火条件を満たしたときに発火と見なします。具体的な発火条件に関してはこの後のセクションで説明します。

続きを読む

UICollectionViewのCompositional Layoutsでセル全体にドロップシャドウをつける方法

OGP

はじめに

こんにちは、フロントエンド部WEARiOSブロックの西山です。

iOS 13から登場したCompositional Layoutsを使うことで、App Storeのような複雑なUIが簡単に実現できるようになりました。

登場前は、UICollectionView in UICollectionViewまたは、UIStackView + UIScrollView in UICollectionViewで頑張って実現していたところをUICollectionView1つで実現できます。

一方で、登場前の方法では簡単に出来ていたカスタマイズをCompositional Layoutsで実現しようとすると難しくなるケースが存在しました。その1つに、横スクロールするセル全体にドロップシャドウを付ける方法が挙げられます。

WEARには次のようなUIが存在します。

WEARのシャドウが付いている画面

このキャプチャ画像では少し分かりづらいかもしれませんが、セル全体にドロップシャドウが付いています。このUIをCompositional Layoutsで実現するのが一筋縄ではいかなかったので、WEARでの解決方法を紹介します。

環境

  • Xcode 13.4.1
  • Swift 5.6.1

一筋縄ではいかなかった理由

1. セルを覆うクラスが公開されていない

Compositional Layouts登場前のWEARでは、UIStackView + UIScrollView in UICollectionViewで実現していました。UIStackViewをラップするような形でシャドウ用のViewを用意する方法を取っていたので比較的簡単に実現できていました。

Compositional Layoutsでも似たような方法を取れれば簡単に実現できますが、残念ながらCompositional Layoutsのセルをラップするクラスは公開されていませんでした。

_UICollectionViewOrthogonalScrollerEmbeddedScrollViewは公開されていない

2. セルにシャドウを付けると繋ぎ目からはみ出る

セルのラップクラスにシャドウを付けることは適わなそうなので、セル1つ1つにシャドウを付ける方法を取りました。しかし、この方法ではセルとセルの繋ぎ目から前後どちらかのシャドウがはみ出してしまいうまくいきません。

前後のシャドウがはみ出す

解決方法

mask layerを利用する

セルにシャドウを付ける方法を取りつつmask layerを利用し、はみ出す部分を隠します。mask layerの簡単なおさらいですが、Viewの切り抜きや穴を開ける方法で語られることが多いと思います。

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .darkGray
        
        // 丸のlayerをセンターに置く
        let maskLayer = CAShapeLayer()
        maskLayer.frame = view.bounds
        
        let width: CGFloat = 200
        let height: CGFloat = 200
        let point = CGPoint(x: view.center.x - width / 2, y: view.center.y - height / 2)
        let rect = CGRect(origin: point, size: .init(width: width, height: height))
        let path = UIBezierPath(roundedRect: rect, cornerRadius: width / 2)
        maskLayer.path = path.cgPath
        
        view.layer.mask = maskLayer
    }
}

背景色darkGrayViewに丸のmask layerをセンターに置いたサンプルコードです。上記コードを実行すると次のようになります。

グレーの丸が切り抜かれる

要するにmask layerと重なる部分が表示されるようになります。

mask layerを使用するためのPosition

やりたいことは、初めのセルの右側、中間のセルの左右、最後のセルの左側を隠すことです。

シャドウを隠したいところ

そのため、どのポジションにいるのか判断できるように型を用意しています。

enum Position {
    case first
    case middle
    case last(isSingle: Bool)
}

extension Position {
    init(index: Int, itemCount: Int) {
        switch (index, itemCount) {
        case let (index, count) where (index + 1) == count:
            self = .last(isSingle: index == 0)
        case (0, _):
            self = .first
        default:
            self = .middle
        }
    }
}

ポジションに合わせてmask layerを定義します。

let width = bounds.width
let height = bounds.height

let maskSpace: CGFloat = 50 // 適切な値を指定
var maskLayer: CALayer? = CALayer()
maskLayer?.backgroundColor = UIColor.black.cgColor

switch position {
case .first:
    // 上、左、下にシャドウが表示されるようlayerを被せる
    maskLayer?.frame = .init(x: -maskSpace,
                             y: -maskSpace,
                             width: width + maskSpace,
                             height: height + maskSpace * 2)
case .middle:
    // 上、下にシャドウが表示されるようlayerを被せる
    maskLayer?.frame = .init(x: 0,
                             y: -maskSpace,
                             width: width,
                             height: height + maskSpace * 2)
case let .last(isSingle):
    if isSingle {
        // 隠したくないのでlayerを削除
        maskLayer = nil
    } else {
        // 上、下、右にシャドウが表示されるようlayerを被せる
        maskLayer?.frame = .init(x: 0,
                                 y: -maskSpace,
                                 width: width + maskSpace,
                                 height: height + maskSpace * 2)
    }
}

WEARでは再利用できるようShadowViewを用意しています。

class ShadowView: UIView {
    private var position: Position?

    override func draw(_ rect: CGRect) {
        super.draw(rect)
        drawShadow()
    }

    func updateShadow(for position: Position) {
        self.position = position
        setNeedsDisplay()
    }

    private func drawShadow() {
        guard let position = position else {
            return
        }

        let width = bounds.width
        let height = bounds.height

        let maskSpace: CGFloat = 50
        var maskLayer: CALayer? = CALayer()
        maskLayer?.backgroundColor = UIColor.black.cgColor

        switch position {
        case .first:
        // mask layerのコード省略
        ...

        layer.shadowOffset = .zero
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowRadius = 10.0
        layer.shadowOpacity = 0.6

        layer.mask = maskLayer
    }
}

この方法を取ることでセル全体へのシャドウをつけることが出来ました。

セル全体へシャドウ適用

shadowPathで更に調整

セル全体へシャドウを適用した時点では、若干繋ぎ目が離れているところが気になります。

繋ぎ目に隙間がある

shadowPathを利用することで、もう少し繋がっている様に見せられます。Viewよりも少し広めにshadowPathを引くことで繋ぎ目を可能な限り消します。ShadowViewdrawShadowに手を加えます。

// mask layerのコードを省略しています
private func drawShadow() {
    let width = bounds.width
    let height = bounds.height

    ... 

    let shadowSpace: CGFloat = 10 // 適切な値を指定
    let shadowPath: UIBezierPath
    
    switch position {
    case .first:
        shadowPath = .init(rect: .init(x: 0,
                                       y: 0,
                                       width: width + shadowSpace,
                                       height: height))

        ...
    case .middle:
        shadowPath = .init(rect: .init(x: -shadowSpace,
                                       y: 0,
                                       width: width + shadowSpace * 2,
                                       height: height))

        ...
    case let .last(isSingle):
        if isSingle {
            shadowPath = .init(rect: .init(x: 0,
                                           y: 0,
                                           width: width,
                                           height: height))

            ...
        } else {
            shadowPath = .init(rect: .init(x: -shadowSpace,
                                           y: 0,
                                           width: width + shadowSpace,
                                           height: height))

            ...
        }
    }

    layer.shadowPath = shadowPath.cgPath
    ...

shadowPathを利用したセル全体へのシャドウ適用

完璧とまではいきませんが、shadowPathを利用することでより良くなったのではないでしょうか。 サンプルコードはわかりやすいようにシャドウを濃くしていましたが、実際はもう少し薄いので馴染んで見えます。

さいごに

Compositional Layoutsでセル全体にドロップシャドウをつける方法を紹介しました。同じ様な悩みを抱えている方の参考になれば幸いです。以前は割と簡単だった実装もCompositional Layoutsでは難しいケースが他にもあるので、今後のアップデートで更にカスタマイズしやすくなることを期待しています。

WEARではiOSエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください。

hrmos.co

WWDC Extended Tokyo 2022を開催しました #wwdc22 #wwdctokyo

opg

こんにちは、ZOZO CTOブロックの@ikkouです。

WWDC Extendedとは

WWDC Extendedは、WWDCのメインセッション(Keynote)をさらに楽しむためのイベントです。これまでのWWDC Extendedはヤフーが単独で開催していましたが、今年のWWDC Extended Tokyo 2022はヤフーに加え、LINEとPayPay、そして私たちZOZOの4社で運営しました。今回のイベントもApple公式のBeyond WWDCにも掲載されています。

yj-meetup.connpass.com

今年のWWDC Extended

ZOZOで普段開催しているMeetupやTech Talkでは、ZoomとOBSを用いてYouTube Liveでライブ配信し、質疑応答にはSlidoを用いています。

WWDC Extendedでは、YouTube LiveとSlidoを用いている点は同じでしたが、ZoomとOBSではなくStreamYardで用いて実施しました。また、休憩中にはMiroを、イベント終了後にはDiscordを用いた交流など、オンラインイベントを盛り上げる複数の取り組みを実施しました。

当日のアーカイブ動画は公開されていませんが、イベントの雰囲気は先行して公開されているYahoo! JAPAN Tech Blogのレポート記事をご覧ください。

techblog.yahoo.co.jp

ZOZOからはZOZOTOWNアプリ部の@banjunが登壇し、WWDC参加にあたってZOZOで実施している作戦会議やラボ戦略を惜しげなく披露しました。

speakerdeck.com

WWDC22での実際のMiro活用事例や、現地参加した3名のエンジニアによる写真を交えたレポート記事は既に公開しているので、ぜひご覧ください。

techblog.zozo.com

最後に

ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。また、今後もグループ間のシナジー効果を生かしたイベントを開催していきたいと考えています。

一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください!

corp.zozo.com

海外出張とオンライン参加で学び、楽しんだ - WWDC22参加レポート

opg

こんにちは、ZOZOTOWNアプリ部の@inokinnです。

inokinn

日本時間の6月7日から11日にかけてWWDC22が開催されました。

今年のハイライトは、iOS 16でのロック画面のアップデートをはじめ、WeatherKitやSwift Charts、Passkeysなどの、数多くの新機能の発表だったかと思います。

今年は去年と一昨年に続いてのオンライン開催に加え、抽選に当選すれば現地であるApple Parkでのパブリックビューイングにも参加できました。そして、なんと幸運にもZOZOからも3名が当選し、現地に赴きました!

本記事では、WWDC22でZOZOのiOSアプリ開発メンバーが取り組んだことを紹介します。また、ラボでAppleのスタッフから得られたフィードバックや、海外出張したメンバーによる現地レポートも可能な範囲で公開します。是非最後までご覧ください。

WWDCについて

WWDC(Worldwide Developers Conference)は、Appleが年に1度開催している開発者向けのカンファレンスです。今年は2019年以来、3年ぶりに現地でもイベントが開催されたので、当選したメンバーは業務の一環として現地参加しました。ZOZOの開発部門では、海外カンファレンスを含むセミナー・カンファレンス参加支援制度が用意されています。

現地で楽しむWWDC22

jun tsutomu tosh

こんにちは、ZOZOTOWNアプリ部の小松、荻野とWEAR部の坂倉です。

コロナ禍になってから完全オンライン開催のWWDCでしたが、なんと今年は一部オフライン開催もありました。案内としては、6月6日のKeynoteやPlatforms State of the UnionなどをApple Parkでライブビューイングできるとのことでした。

現地参加のスケジュールは以下の通りです。

時間(PST) コンテンツ 場所
7:00 AM チェックイン Apple Park Visitor Center
8:00 AM 朝食 Apple Park - Caffè Macs
10:00 AM Keynote Apple Park
12:00 PM 昼食 Apple Park - Caffè Macs
1:00 PM Platforms State of the Union Apple Park
2:30 PM Meet the Teams Apple Park - Caffè Macs
4:30 PM Apple Design Award Apple Park

また、今年は参加記念品として以下のものをもらえました。個人的にお気に入りなのは、Swiftロゴの入ったトートバックです。MacBook Proも入るサイズで愛用しています。

WWDC22 参加記念品 swift

6月5日 - イベント前日

6月5日はイベント前日ではありますが、アーリーチェックインが可能になっており、先行でDeveloper Centerのオープンハウスも開催されていました。

会場はお祭りさながらの雰囲気で非常に盛り上がっていました。

Special Event at Apple Park前日の会場の様子

早々にチェックインを済ませ、Developer Centerへ。

Developer Center

Developer Centerはエンジニアやデザイナーが交流したり学ぶための施設とのことですが、Apple Storeさながらのお洒落な部屋がいくつも用意されていました。

実際にコードを書いて学べるワークショップ用の部屋や、壁に巨大なホワイトボードが設置されたUI設計用の部屋、製品設計用の部屋などがありました。あまりの綺麗さに一度はここで仕事をしてみたいと思いました(ちなみに各部屋はこれまでのmacOSの名前が付けられています)。

Developer Centerの部屋の様子

一番驚きだったのが、Big Surと呼ばれる放送スタジオです。

そこはまるで映画館のような空間になっており、小さな文字もしっかり読める高解像度の超巨大モニターや、色々な角度から音を鳴らすことができるサウンドシステムには度肝を抜かれました。

放送スタジオということで、ここでAppleが観客の前でKeynoteを催すことはないのかもしれませんが、この空間で一度Keynoteを観てみたいと思いました。

Developer CenterのBig Surスタジオ

6月6日 - イベント当日

apple park live viewing

ライブビューイング

6月6日、イベント当日。朝の入場待ちの列ではコーヒーが配られ、これから初公開のApple Parkへと足を踏み入れる人たちの熱気に包まれていました。

Appleのスタッフに歓迎されながら道を進むと、ついにApple Parkが姿を現します。汚れひとつない全面ガラス張りの外観は息を呑む美しさでした。

apple park office

Apple Parkの中に入って朝食を受け取り、大きなスクリーンが置かれている広場であるCaffè Macsへ進みます。

メインスクリーンの正面の部分は窓ガラスが可動式になっており、屋内・屋外どちらからでもスクリーンが見られるように開かれていました。こんなに大きな窓ガラスが本当に動くのか…と規格外の大きさに圧倒されてしまいます。

Keynoteが始まるまでの時間ではAppleのスタッフがいたるところで踊っていたりとお祭りムードでした。

画面が暗転し、ついにKeynoteのライブビューイングが始まると思いきや、そこに現れたのはなんとティム・クックとクレイグ・フェデリギ!

座っていた参加者は全員立ち上がり、先ほどまでの熱気がより一層強まりました。

tim cook craig federighi

Keynote本編では、iOS 16や新型MacBook Airをはじめ、さまざまな新機能の発表がありました。新しい機能が発表されるたびに起こる拍手や歓声で喜びを共有できるのは、現地ならではの良さだなぁと感じました。

ちなみに、Keynoteのライブビューイングの中で最大の盛り上がりを見せたのは、超高速で移動するフェデリギ氏がスーパースローで髪を掻き上げているシーンでした(笑)。

Keynoteが大盛況の中終わると、お昼休憩を挟んでPlatforms State of the Unionが始まります。

State of the UnionではXcode CloudやSwiftUI、iOS 16のアップデートに伴うWidgetKitなど、新機能の紹介が行われました。ここでもKeynoteと同様に、リアクションが会場全体から漏れてきます。

ライブビューイングが終了した後は、Apple Park内の公開されているエリアを探索しました。

Appleのスタッフによると、3階からのCaffè Macsの景色がApple Parkで1番美しいスポットとのことでした。3階まで登ると円形になっている建物の全体を見渡すことができ、1番というのも納得の光景が広がっていました。

view from Caffè Macs

また、探索中にAppleのスタッフからAppleの環境や運動に対する取り組みに関する話を聞くことができました。

Apple Parkは建物部分が敷地全体の20%しかないらしく、緑との共生を大切にしているとのことでした。また、こうして緑を多くすることによって積極的に外に出るような環境を作り、他のチームとの交流や歩きながらのミーティングを促進しているらしいです。

Apple Park内にはバスケットボールのコートやテニスコート、サッカー場などがあり、日本では絶対にできないような土地の使い方を見ると、さすがアメリカだなぁと感じます。

Meet with Teams

Platforms State of the Unionが終わると、Caffè MacsではMeet with Teamsというイベントが行われていました。ここではさまざまな分野のAppleのエンジニアが常駐しており、気軽に雑談ができました。実際に、自分はXcodeエンジニアとFitnessエンジニア、UIKitエンジニアと話しました。

Xcodeエンジニアとは、Xcode AppはXcodeで作られているのか、Appleのエンジニアだったら常に最新のMacに交換し放題なのかといった話をしました。こういったカジュアルな雑談ができるのはラボとの大きな違いだった思います。専門的な話になるとラボを勧められるといった雰囲気でした。

オンラインで楽しむWWDC22

海外出張した3名以外の、ほとんどのメンバーはオンラインでWWDC22に参加しました。開催期間中、ZOZOTOWNアプリ部のメンバーは、現地の時間に合わせて日本時間2:00〜11:00を勤務時間としていました。

チーム内で情報を共有するため、毎日1回、ビデオ通話でのミーティングを行っていました。また、分担して視聴したセッションのサマリや、ラボやラウンジで得た情報はMiroに一元管理して共有していました。色々な情報が共有されたため、WWDC22の全日程が終了した後にはボードの様子は以下のようになっていました。

WWDC22終了後のMiroの様子

このやり方は去年のノウハウを活かしたもので、下記のWWDC21参加レポートにより詳しく公開しているので、よろしければこちらもご覧ください。

techblog.zozo.com

Digital Lounges

WWDC22では、去年に引き続きオンラインならではの取り組みが実施されていました。

「Digital Lounges」はその1つで、Slackを用いて、Appleのエンジニアやデザイナーにチャットで質問することが出来ました。

質問以外にも、ラウンジ上では「Trivia Night」というイベントも開催されていました。これは、Appleプラットフォームに関するトリビアクイズが出題されるというものです。プログラムの実行結果に関する問題や「Apple社の祝日はいつ?」といったApple愛を試されるクイズも出題されました。

Trivia Night

Challenges

「Challenges」も、オンラインならではのコンテンツでした。こちらは、いくつかの提示されたお題の中から好きなものに取り組むことが出来るコンテンツです。取り組んだ結果をラウンジでAppleのスタッフや世界中のiOSアプリ開発者たちに公開して意見をもらったり、SNSで共有して盛り上がったりすることが出来ます。お題にはバリエーション豊かなものが毎日追加されるので、興味のあるものに挑むのが楽しかったです。

私も「Pixel perfect design」という、アプリのアイコンをピクセルアートで表現するお題に挑戦し、弊社アプリ「ZOZOTOWN」のアイコンを描いてみました!

Pixel perfect designのフィードバック

Digital Loungesで見ていただいたところ、お褒めの言葉と同時に「文字のラインを2pxではなく1pxで表現してみては?」というフィードバックをいただけました。得られたフィードバックを反映し、ピクセルアートをこのように改善することが出来ました。

TAKE 1 TAKE 2 TAKE 3
ZOZOTOWNピクセルアートTAKE 1 ZOZOTOWNピクセルアートTAKE 2 ZOZOTOWNピクセルアートTAKE 3

この作品はApple公式サイトの WWDC22 Daily Digest: Wednesday にも取り上げていただけました!

WWDC22 Daily Digest: Wednesday

Source: https://developer.apple.com/news/?id=pcfa7nkx

Labs & Sessions

今年も、ラボではAppleのスタッフからフィードバックを得ることが出来ました。WWDC22に参加したZOZOメンバーから、それぞれが参加したラボで得たフィードバックや、視聴したセッションで得た内容を一部紹介します。

Design Lab × FAANS

memoji_nakaji こんにちは、FAANS部iOSチームの中島です。Design LabでAppleのデザイナーにFAANSアプリのフィードバックを頂いたので、紹介いたします。

投稿フローに対するフィードバック

FAANSはWEARと同様にコーディネートを投稿でき、投稿写真の明るさ調整の機能があります。スライダーで「明るさ、コントラスト、彩度」の数値を変更するのですが、この機能に対し「プリセットを提供し、ユーザーが簡易的に明るさを調整できるようにしてはどうか」というフィードバックをいただきました。投稿フローにかかる時間の短縮方法を検討していましたので、今後の改善の参考にしたいと思います。細かいフィードバックをいただいたものの、コーディネート投稿フロー全体として完成度が高い、といっていただけたので今後の開発の励みになりました。

明るさ調整

着用アイテムを登録する機能に対するフィードバック

投稿するコーディネートに着用アイテムを紐付けする機能があります。具体的にブランドをどのように選べばいいのか、という質問を受けました。品番/バーコードでの登録、お気に入り(クローゼット)からの登録、カテゴリ、カラー、ブランドを入力して該当の商品を一覧から探して登録、の3種類の方法があります。選択肢が多いことはユーザーを迷わせる原因になると思いましたので改善していきたいです。

着用アイテムを登録

通知の活用

FAANSはショップスタッフが主なユーザーです。コーディネート投稿機能に加え、在庫取り置きの機能があります。今のUIだとコーディネート投稿の結果確認がメインになっており、店舗取り置きの導線が少しわかりにくいという問題点に対し、どのように思うか質問しました。店舗取り置きがある場合、見逃さないようにする必要があるのでアイコンに赤い丸をつける等、通知という形でわかれば問題ないという回答をいただきました。

Design Labを利用した感想

FAANSアプリはWEARやZOZOTOWNとも連携する部分があり、英語での説明も相まって、そもそもどのようなアプリなのか説明するのが難しかったです。ユーザーが限られているというのはあるのですが、対象のユーザーでない人でも直感的に理解、操作できるように改善していきたいです。Design Labを利用したのは初めてでしたが、とても有意義なラボでしたので来年も利用したいです。

WeatherKitはWEARを拡張するか

しょうご

こんにちは、WEAR部iOSブロックのしょうごです。個人的にファッションと天気の相関性について、以前より注目をしていました。

なぜなら、冷夏や暖冬といった異常気象の季節には、季節物の販売不振に陥るケースがあります。そのため、ファッションコーディネートアプリのWEARとしても、ファッション業界の動向は注視する必要があると思います。そこで、異常気象によるWEARへの悪影響を想定した場合、アパレル商品の流通量の低下の影響から、ユーザーログイン頻度の低下などは考えられると思います。故に、異常気象の影響を少しでも緩和できる仕組みが備わっていれば尚良いと思っており、以下の様な事を考えていました。

  • WEAR自体が天気情報をユーザーにPUSH通知を用いて知らせ、WEARアプリに誘導する
  • 多くの人々は外出する前に天気予報を確認するため、アプリのユーザーにとってもメリットがある
  • アプリを開いた後ユーザーの位置情報をもとに、気温や湿度情報などからよく使われる服をレコメンドする
  • 結果として、人々のファッションの悩みを解決するというWEARのミッションも果たせるかもしれない

今回発表のあった内容によるとWeatherKitは、非常に詳細な気象データを取得できるようです。詳細な気象データを取得可能な理由として、高解像度の気象モデルと機械学習および予測アルゴリズムを使用し導き出しているとセッション中で説明がありました。取得できる情報の詳細はMeet WeatherKit - WWDC22をご参照下さい。

読者の皆さんはWeatherKitに関してどの様な印象をお持ちになりましたか? 技術は使い方次第で可能性は無限大です。WEARに関して考えてみると、個人的にWeatherKitとの親和性は高いと考えています。なぜなら、WeatherKitの精度次第では、天気情報をベースとした新たなファッションの提案が可能になると考えたからです。また、既存のWEARを拡張してユーザーとのタッチポイントを増やす事ができるというシナジーも期待できます。今後WEARならではの新たなサービスを生み出す事も、可能かもしれません。そして、コーディネートと天気の親和性が高いファッション業界に、ZOZOらしくテクノロジーの力を用いて一石を投じる可能性もあると考えています。

まとめ

本記事では、WWDC22の参加レポートをお伝えしました。

海外出張で現地参加したメンバーも、オンラインでリアルタイムに情報をキャッチアップしたメンバーも、それぞれに実りあるイベントでした。今回得られた知見やフィードバックをもとに、より良いサービスの向上に努めていきたいと思います!

さいごに

ZOZOでは、一緒にモダンなサービス作りをしてくれる仲間を募集しています。ご興味のある方は、以下のリンクから是非ご応募ください!

corp.zozo.com

hrmos.co

hrmos.co

hrmos.co

【オンラインMeetup イベントレポート】ZOZO Tech Talk #6 - iOS

こんにちは、ZOZO CTOブロックの@ikkouです。

ZOZOでは、5/16にZOZO Tech Talk #6 - iOSを開催しました。

zozotech-inc.connpass.com

本イベントは、これまで夕刻に開催してきたMeetupとは異なり、ランチタイムに開催する「ZOZO Tech Talk」シリーズです。ZOZO Tech Talkでは、ZOZOがこれまで取り組んできた事例を紹介していきます。

第6回はネイティブアプリ開発の中で、特にiOSにフォーカスした内容を発表しました。

登壇内容 まとめ

弊社の社員2名が登壇しました。

  • Hapticをカスタマイズしてみよう (ZOZOTOWN開発本部 ZOZOTOWNアプリ部 / 遠藤 万里)
  • Apple silicon導入のウラガワテックブログに盛り込めなかった話、公開します (ZOZOTOWN開発本部 ZOZOTOWNアプリ部 / 小松 悟)



最後に

ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。

一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください!

corp.zozo.com

WEARに動画投稿を実装した際にハマった事とその解決策

OGP

はじめに

こんにちは。WEAR部iOSチームの坂倉です。先日、WEARにコーディネート動画の投稿機能を実装しました。

iOSで動画を扱うにはAVFoundationを使う必要がありますが、原因がわかりにくいエラーを引き起こすことが多々あり、実装になかなか苦労しました。

この記事では、動画投稿の開発中に起きた問題とその解決法をお伝えします。

WEARの動画投稿には以下の機能が存在します。

  • 動画を選択する
  • 動画をプレビューする
  • 動画に付与する音楽を選択する
  • 動画に付与する音楽の範囲を指定してトリミングする
  • 動画に関する情報を付与する
  • 動画と音楽をミックスしてエンコードする
  • 完成した動画を投稿する

WEARのコーディネート動画投稿フロー

これらを実装する中で、2つの問題に直面しました。

  • 特定の動画が原因不明のエラーでエンコードできない
  • 音楽の再生と合わせて一部の波形のみをアニメーション付きで着色する処理の設計

それぞれの解決方法を下記にてお伝えいたします。

特定の動画が原因不明のエラーでエンコードできない

動画といっても色々な種類があり、この動画のエンコードは問題ないが、あの動画はエラーが出てしまうといったことが多々ありました。

ここでは、その時の対処法について解説します。

ちなみに、WEARで使用しているエンコードのコードは以下の通りです(一部省略)。

エンコードコード(クリックで展開)

// 動画と音楽のURLからAVURLAssetを生成
let videoURLAsset: AVURLAsset = .init(url: videoPath)
let audioURLAsset: AVURLAsset = .init(url: audioPath)
guard let videoAssetTrack = videoURLAsset.tracks(withMediaType: .video).first,
      let audioAssetTrack = audioURLAsset.tracks(withMediaType: .audio).first
else {
    return
}
// AVURLAssetから動画+音楽を追加し、AVAssetExportSessionに必要なコンポジションを作成
let composition: AVMutableComposition = .init()

// 動画をAVMutableCompositionに追加
guard let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else {
    return
}
try? videoTrack.insertTimeRange(videoAssetTrack.timeRange, of: videoAssetTrack, at: .zero)

// 範囲を指定し音楽をAVMutableCompositionに追加(ここでは音楽の長さは動画の長さと同じにする)
guard let audioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else {
    return
}

let audioTimeRange: CMTimeRange = .init(start: .zero, end: videoTrack.timeRange.end)
try? audioTrack.insertTimeRange(audioTimeRange, of: audioAssetTrack, at: .zero)


// 動画を回転させるためにAVMutableVideoCompositionLayerInstructionを生成する
let videoCompositionLayerInstruction: AVMutableVideoCompositionLayerInstruction = .init(assetTrack: videoTrack)
let transform = makeTransform(with: videoTrack)
videoCompositionLayerInstruction.setTransform(transform, at: .zero)


// AVMutableVideoCompositionInstructionに動画の時間と回転情報を渡す
let videoCompositionInstruction: AVMutableVideoCompositionInstruction = .init()
videoCompositionInstruction.timeRange = videoTrack.timeRange
videoCompositionInstruction.layerInstructions = [videoCompositionLayerInstruction]


// 動画の解像度やframeDurationカラー情報をAVAssetExportSessionに渡すためのAVMutableVideoCompositionを生成
let videoComposition: AVMutableVideoComposition = .init()


// iOSの画面収録で撮った動画がiOS 14でエンコードできないで解説します
videoComposition.colorPrimaries = AVVideoColorPrimaries_ITU_R_709_2
videoComposition.colorTransferFunction = AVVideoTransferFunction_ITU_R_709_2
videoComposition.colorYCbCrMatrix = AVVideoYCbCrMatrix_ITU_R_709_2


// 写真アプリでトリミングした動画がエンコード出来ないで解説します
let fps = max(videoAssetTrack.nominalFrameRate, 1.0)
videoComposition.frameDuration = CMTime(value: 1, timescale: CMTimeScale(fps))
videoComposition.renderSize = CGSize(width: 1080.0, height: 1920.0) // 解像度を指定
videoComposition.instructions = [videoCompositionInstruction]


// AVMutableCompositionとAVMutableVideoCompositionで動画+音楽ソース+解像度などの詳細を渡し、動画を生成
guard let assetExportSession: AVAssetExportSession = .init(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
    return
}
guard let documentPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first else {
    return
}
assetExportSession.videoComposition = videoComposition
// AVMutableVideoCompositionInstructionにも指定しているがここでも指定しないとエラーが出る
assetExportSession.timeRange = videoTrack.timeRange
assetExportSession.outputFileType = .mp4
let videoFilePath = "\(documentPath)/tmp.mp4"
assetExportSession.outputURL = URL(fileURLWithPath: videoFilePath)
assetExportSession.shouldOptimizeForNetworkUse = true
assetExportSession.exportAsynchronously {
    switch assetExportSession.status {
    case .completed:
        print(assetExportSession.outputURL!)
    @unknown default:
        return
    }
}

iOSの画面収録で撮った動画がiOS 14でエンコードできない

色々な動画のエンコードを試す中で、どうしてもエンコードできない動画がありました。それは、iOSの画面収録で撮影した動画です。

AVAssetExportSessionはエラーを出力してくれますが、エラーを見ても原因を突き止めるのが困難な内容でした。

Error Domain=AVFoundationErrorDomain Code=-11800 "操作を完了できませんでした" UserInfo={NSLocalizedFailureReason=原因不明のエラーが起きました(-12212), NSLocalizedDescription=操作を完了できませんでした, NSUnderlyingError=xxxx {Error Domain=NSOSStatusErrorDomain Code=-12212 "(null)"}}

そのため、エラーコードに注目しました。-12212 と表示されていたので調べたところkVTColorCorrectionPixelTransferFailedErrというエラーだとわかりました。

stackoverflow.com developer.apple.com

色に問題ありということで、問題の動画をQuickTime Playerのムービーインスペクタで確認しました。

すると、エンコードできる動画と比べ、画面収録で撮った動画はTransfer FunctionがsRGBとなっており、Appleの Setting Color Properties for a Specific Resolution に記述されている設定例に無い値になっていました。

エンコードできる動画とできない動画のムービーインスペクタ

そのため、「この動画はAVAssetExportSessionに対応していない」という仮説を立て、色指定を変更することでエンコード出来るか試してみました。

AVVideoCompositionは、動画の色空間情報を設定するためのプロパティを3つ持っています。

これらを、Appleの Setting Color Properties for a Specific Resolution に記述されている例を元に設定してみました。(10-bit wide gamut HDはAVVideoSettingsのドキュメントコメントに記述されています)。

なんと、OSのバージョンによって違いが出る結果になりました。

OS別エンコード確認表(○は可、×は不可)

設定無 HD SD wide gamut HD 10-bit
wide gamut HD
iOS 15 ○(HD化) ○(HD化) ○(HD化)
iOS 14 × × ×
iOS 13 ×

iOS 15は、どのパターンでもエンコードできました。iOS 13/14は、HD/wide gamut HD以外だとエンコードエラーになりました。

面白いのは、iOS 15の場合、設定なし/SD/10-bit wide gamut HDの場合、色空間がHDと同じになることがわかりました。この結果を見る限り、iOS 15は変換できなかった場合システム側でHDに色を揃えていそうですね。

この結果を見て、HD(Rec.709)が全ての対応OSでもエンコードできて一般的な規格である事から、WEARはHDの設定を使用する事にしました。

// For HD colorimetry, specify
let videoComposition: AVMutableVideoComposition = .init()
videoComposition.colorPrimaries = AVVideoColorPrimaries_ITU_R_709_2
videoComposition.colorTransferFunction = AVVideoTransferFunction_ITU_R_709_2
videoComposition.colorYCbCrMatrix = AVVideoYCbCrMatrix_ITU_R_709_2

ちなみに、動画の色指定に関して丁寧に説明しているAppleのドキュメントがあるので一見の価値ありです。

developer.apple.com

写真アプリでトリミングした動画がエンコードできない

写真アプリでトリミングした時間が短い動画を指定するとエンコードエラーになる事象もありました。デバッグログを見ると以下のようなエラーが出ていました。

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[AVAssetExportSession setVideoComposition:] video composition must have a positive frameDuration'

CMTimeScaleは整数であることが求められますが、あまりに短い動画だとnominalFrameRateで得られるフレームレートは1を下回ることもあります。 なので、最低値を1.0にして対応しました。

let fps = max(videoTrack.nominalFrameRate, 1.0)
videoComposition.frameDuration = CMTime(value: 1, timescale: CMTimeScale(fps))

動画をmp4で出力する場合、mp3を使うと出力できない問題

Appleのドキュメントには記述が見つからなかったのですが、mp3の音楽ファイルを取り込んでmp4の形式で動画を出力する場合エラーになりました。

StackOverflowによると、movとcaf以外の形式ではmp3を使うことはできないようです。

stackoverflow.com

そのため、WEARではm4aのみを使いmp3は使わないようにしています。

AVMutableVideoCompositionLayerInstructionは、AVAssetTrackを使って生成するとエンコード出来ない場合がある

特定の動画で、なぜかエンコードできないことがありました。エラーの内容は以下の通り。

Error Domain=AVFoundationErrorDomain Code=-11841 "操作が停止しました" UserInfo={NSLocalizedFailureReason=ビデオを作成できませんでした。, NSLocalizedDescription=操作が停止しました, NSUnderlyingError=... {Error Domain=NSOSStatusErrorDomain Code=-17390 "(null)"}}

結論から言うと、AVMutableVideoCompositionLayerInstructionを生成するときに渡していたassetTrackに原因がありました。

🙅‍♀️な例

 guard let videoAssetTrack = videoURLAsset.tracks(withMediaType: .video).first else { return }
let videoCompositionLayerInstruction: AVMutableVideoCompositionLayerInstruction = .init(assetTrack: videoAssetTrack)

🙆‍♂️な例

guard let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { return }
let videoCompositionLayerInstruction: AVMutableVideoCompositionLayerInstruction = .init(assetTrack: videoTrack)

AVMutableVideoCompositionLayerInstructionのイニシャライザを見ると、assetTrackの型がAVAssetTrackだったので間違ってしまいましたが、AVMutableCompositionのaddMutableTrackのAVMutableCompositionTrackを指定するのが正でした(ちなみにAVMutableCompositionTrackの親の親はAVAssetTrack)。

気付きづらいのが、全ての動画がエンコードできないというわけではないということです。完全に原因を特定できてはいませんが、写真アプリ以外で編集した動画はこの現象が起こりやすい印象でした。

StackOverflowに対処法があったことで気づくことができましたが、これはなかなかの罠ですね。皆さんもご注意ください。

stackoverflow.com

音楽の再生と合わせて一部の波形のみをアニメーション付きで着色する処理の設計

WEARの動画投稿には、動画に付与する音楽をトリミングできる画面が存在します。

波形で枠内だけを着色する

この画面には、スクロールを止めたタイミングで音楽のループ再生に合わせて枠内(UIScrollViewのスクロール領域のみ)の波形をアニメーション付きで着色する実装が求められました。

開発当初はなかなか上手い実装方法が思い浮かびませんでした。

「音楽のループ再生とアニメーションをどう同期させるか?」「波形画像の一部だけを着色するにはどんな方法でやるのがシンプルか?」「スクロールを邪魔せずどう実装するか?」などを一つ一つ考慮した結果、AVQueuePlayer、AVPlayerLooper、UIGraphicsImageRenderer、UIViewのmask、CAKeyframeAnimationを組み合わせることで実装できました。

踏んだ手順は以下の通りです。

  1. AVQueuePlayer+AVPlayerLooperでループ再生を実装
  2. UIScrollViewに波形の画像を入れる
  3. スクロールが止まったタイミングで音楽を再生しスクロール量を元に枠内の波形を切り出す
  4. 切り出した波形の画像をアニメーション用のViewのmaskに追加
  5. 音楽再生のタイミングでCAKeyframeAnimationを使って波形をアニメーション付きで塗る

各項目を最小限のコードで説明します。

1. AVQueuePlayer+AVPlayerLooperでループ再生機能を実装する

まず、音楽を繰り返し再生する必要があるためAVQueuePlayer+AVPlayerLooperでループ再生を実装します(AVAudioEngineを使う方法もありますが今回は再生だけで良いのでAVQueuePlayerを使いました)。

また、音楽の再生が完了するたびに着色を初めからやり直したいため、NSKeyValueObservationを使ってAVPlayerLooperのloopCountの状態を監視しています。

final class AudioPlayer {
    private let asset: AVAsset
    private let playerItem: AVPlayerItem
    private let player: AVQueuePlayer
    private var playerLooper: AVPlayerLooper?
    private var playerLooperObservation: NSKeyValueObservation?

    init(withAudioFilePath audioFilePath: URL) {
        asset = .init(url: audioFilePath)
        playerItem = .init(asset: asset)
        player = AVQueuePlayer(items: [playerItem])
    }

    // rangeは音楽の再生範囲(秒)
    func play(range: ClosedRange<Double>, completion: @escaping (()) -> Void) {
        player.removeAllItems() // 再生範囲を変えるにはAVPlayerLooperを作り直す必要があるためリセット。これがないとクラッシュする。
        playerLooper = AVPlayerLooper(
            player: player,
            templateItem: playerItem,
            timeRange: CMTimeRange(range: range, timescale: asset.duration.timescale)
        )
        player.play()
        playerLooperObservation = playerLooper?.observe(\.loopCount, options: [.new]) { playerLooper, _ in
            guard playerLooper.loopCount > 0 else { return }
            completion(())
        }
    }
}

extension CMTimeRange {
    init(range: ClosedRange<Double>, timeScale: CMTimeScale) {
        let start: CMTime = .init(seconds: range.lowerBound, preferredTimescale: timeScale)
        let end: CMTime = .init(seconds: range.upperBound, preferredTimescale: timeScale)
        self = .init(start: start, end: end)
    }
}

2. UIScrollViewに波形の画像を入れる

次に、音楽の波形画像をUIScrollViewに追加します。

UIScrollViewに入れた波形は、UIScrollViewのframeから出ても描画したいので、scrollView.clipsToBounds = false にします。

UIScrollViewの図

final class AudioRangeView: UIView {
    @IBOutlet private var scrollView: UIScrollView!
    
    override func awakeFromNib() {
        super.awakeFromNib()
      
        guard let waveFormImageView: UIImageView = .init(image: waveFormImage) else { return } 
        scrollView.addSubview(waveFormImageView)
        scrollView.contentSize = CGSize(
            width: waveFormImageView.bounds.size.width,
            height: waveFormImageView.bounds.size.height
        )
        scrollView.clipsToBounds = false // スクロールバーのframe以外のcontentViewを描写する
    }
}

3. スクロールが止まったタイミングでスクロール量を元に枠内の波形を切り出す

次は、枠内の波形のみを着色するためUIScrollViewの枠内に入っている波形画像を切り出します。

波形の着色は、スクロールが止まった時に行うため、UIScrollViewDelegateのscrollViewDidEndDeceleratingとscrollViewDidEndDragging上で行います。

スクロールが止まったタイミングで着色する

scrollView.contentOffset.x(スクロール量)を使って枠内の波形のポジションを取得しUIGraphicsImageRendererで切り出します。

final class AudioRangeView: UIView {
    // 以下略
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        let cropImage: UIImage = UIGraphicsImageRenderer(size: scrollView.bounds.size).image { context in
            waveFormImageView.image?.draw(at: CGPoint(x: -scrollView.contentOffset.x, y: .zero))
        }
        animationView.startAnimation(image: image, animationDuration: 10.0)
    }
}

4. 切り出した波形の画像をアニメーション用のViewのmaskに追加

次に、UIScrollViewの上に同サイズのアニメーション用のViewを置きます。

このViewに先ほど切り出した波形画像を渡し、これを着色することで、UIScrollViewの中に収まった波形だけが塗られていくように見せます。

波形画像を着色するにはmaskを利用します。maskに切り出した波形画像を入れます。

final class WaveFormColoringAnimationView: UIView {
    @IBOutlet private var liquidView: UIView!

    func startAnimation(image: UIImage, animationDuration: Double) {
        mask = UIImageView(image: image)
    }
}

そして、着色用のViewをアニメーション用のViewの一番上に貼ります。

アニメーション用のViewのxib

このViewには波形に着色したい色をアニメーションを開始するタイミングでbackgroundColorに指定します。

func startAnimation(image: UIImage, animationDuration: Double) {
    mask = UIImageView(image: image)
    liquidView.backgroundColor = .blue
}

次に、CAKeyframeAnimationを用いて切り出した波形を左から右に着色します。

durationには音楽の再生時間を指定します。こうすることで再生と共にアニメーションが進むようになります。

final class WaveFormColoringAnimationView: UIView {
    private var liquidView: UIView!
    
    func startAnimation(image: UIImage, animationDuration: Double) {
        mask = UIImageView(image: image)
        liquidView.backgroundColor = .blue

        let animation: CAKeyframeAnimation = .init(keyPath: "position.x")
        animation.values = [-(liquidView.bounds.size.width * 0.5), liquidView.bounds.size.width * 0.5]
        animation.duration = CFTimeInterval(animationDuration)
        animation.isRemovedOnCompletion = false
        liquidView.layer.add(animation, forKey: "coloringAnimation")
    }
}

ループ再生が終わったタイミングでアニメーションを止めたいので、そのためのメソッドも用意しておきましょう。

final class WaveFormColoringAnimationView: UIView {
    // 以下略
    func stopWaveFormColoringAnimation() {
        liquidView.layer.removeAllAnimations()
        liquidView.backgroundColor = .clear
        mask = nil
    }
}

5. 再生と同時にアニメーションを実行して枠内の波形を着色する

あとはスクロールが止まったタイミングで、音楽の再生と着色処理を同時に実行すれば、ループ再生中、枠内の波形だけ着色します。

アニメーションの時間指定には、音楽の再生時間と同じ値を入れるのをお忘れなく。

final class AudioRangeView: UIView {
    private let audioPlayer: AudioPlayer
    @IBOutlet private var animationView: WaveFormColoringAnimationView!

    override init(frame: CGRect) {
        super.init(frame: frame)
        audioPlayer = AudioPlayer(url: audioFilePath) // 音楽のローカルパスを指定
    }

    // 以下略

    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        let cropImage: UIImage = UIGraphicsImageRenderer(size: scrollView.bounds.size).image { context in
            waveFormImageView.image?.draw(at: .init(x: -scrollView.contentOffset.x, y: .zero))
        }

        let playRange: ClosedRange<Double> = 0.0 ... 10.0
        let animationDuration = playRange.upperBound - playRange.lowerBound

        animationView.startAnimation(image: cropImage, animationDuration: animationDuration)
        audioPlayer.play(range: playRange, completion: { [weak self] in
            guard let self = self else { return }
            DispatchQueue.main.async {
                self.animationView.stopWaveFormColoringAnimation()
                self.animationView.startAnimation(image: cropImage, animationDuration: 10.0)
            }
        })
    }

    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        guard !decelerate else { return }
        // 同様の処理
    }
}

さいごに

AVFoudationは扱いづらい事もありますが、段々慣れてくると非常に楽しくなってきますね。この記事が、誰かの動画開発の一助になれば幸いです。

ぜひ、お気に入りのコーデ動画を投稿してくれると嬉しいです。よろしくお願いします。

WEARでは、今後もどんどん動画の開発を進めていきます。ご興味のある方は以下のリンクからぜひご応募してください!

hrmos.co

Combineの非同期処理をSwift Concurrencyのasync/awaitで書き換えてみた

OGP

こんにちは、FAANS部の中島 (@burita083) です。2021年10月に中途入社し、FAANSのiOSアプリの開発を行なっています。

FAANSの由来は「Fashion Advisors are Neighbors」で、「ショップスタッフの効率的な販売をサポートするショップスタッフ専用ツール」です。現在正式リリースに向けて、WEARと連携したコーディネート投稿機能やその成果を確認できる機能など開発中です。

はじめに

FAANS iOSでは非同期処理にCombineを利用しています。Combine自体は本記事では詳しく解説をしませんが、RxSwiftを利用したことがある方なら特に違和感なく使えるかと思います。全く馴染みがない場合だと覚えることも多く、難しいところもあるかと思いますので、Swift Concurrencyを利用する方が理解しやすいかもしれません。ただし、ViewとPresenterの値のバインディング処理にも利用していますので、FAANS iOSでは当面、Combineも利用していくと思われます。

今回、async/awaitで書き換えた理由として、主に2つの理由があります。

  • 非同期処理をシンプルに書けるようになるため

Combineのコードは、コールバックで受け取る必要があり、コールバックの中でさらに別のAPIを叩く場面もあります。async/awaitで手続型のように書けるので、シンプルな記述が可能です。本記事で実際のコード例を元に説明します。

  • Swiftのアップデートに追従しつつ、チームとして継続的に新しい技術に触れることで成長していきたいため

URLSession等、Apple標準のAPIでasync/awaitがすでに使われており、今後も様々な機能がアップデートされます。キャッチアップした内容を業務で積極的に活用できる環境づくりをチームで心がけています。

本記事ではCombineでの非同期通信の処理に対しasync/awaitで書き換えたユースケースを紹介し、実装のポイント等、説明します。

続きを読む

ZOZOTOWN iOSチーム、Apple silicon導入しました

ogp

はじめに

こんにちは、ZOZOアプリ部でZOZOTOWN iOSアプリを開発している小松です(@tosh_3)。ZOZOTOWN iOSチームでは、M1 Pro / M1 Max発売のタイミングでチーム内の開発環境をApple siliconへと移行しました。スムーズに移行するためにどのようなことを実践したのかと実際に移行することでどのような恩恵を受けることができたのかを紹介します。

Apple siliconについて

WWDC 2020にてAppleはIntelプロセッサーからApple siliconと呼ばれるAppleによってデザインされたプロセッサーへと移行していくことを発表しました。開発者用にDTK(Developer Transition Kit)が配布されたのち、2020年の11月に一般用としてM1プロセッサーが、そして2021年の10月にはアップデートされたM1 Pro / M1 Maxのプロセッサーが発表されました。Apple siliconでは、ARM64と呼ばれるCPUアーキテクチャを採用していたことから、今までのIntelのプロセッサーとはアーキテクチャが異なります。そのため開発者はApple silicon下でも問題なく動くのかを確認する必要がありました。

検証機の導入

ZOZOTOWN iOSチームではM1 Proよりもさらに前、M1が発売されたタイミングで、M1のMacBook Airをチーム内に検証機として一台導入しました。

検証機を導入した背景は以下の通りです。

  • Apple siliconでも今まで通り業務ができるのか担当部門で検証中であったため、業務用PCの置き換えよりも検証機としての導入の方が好ましかった
  • メモリを16GBまでしか積めず、32GB or 64GBまで積める、よりプロフェッショナルなモデルのリリースが予想されていた
  • チーム全体として開発環境の移行にかかる時間を減らしたかった

検証にあたって意識した点は以下の通りです。

  • ZOZOTOWN iOSアプリを問題なくビルドすることが可能か
  • 通常の業務で使用にあたり不自由ない環境であるか
  • 導入することによってどのような効果を得ることができるのか

ZOZOTOWNのビルドについて

結論から言ってしまうとApple silicon環境下ではZOZOTOWNのアプリはビルドできませんでした。正確にいうとRosettaの使用なしにはビルドができなかったのです。

ZOZOTOWNはアプリ内で、ZOZOSUITやZOZOMAT、ZOZOGLASSのような計測機能を備えています。これらの計測機能はフレームワークとしてZOZOTOWN内に入っています。また、これらのうちZOZOSUITとZOZOMATはCarthageで管理されており、ZOZOGLASSのみCocoaPodsで管理されています。

Apple siliconの導入にあたって、Carthageで管理されているフレームワークをXCFrameworksとして扱う必要があります。しかし、このZOZOSUITとZOZOMATをXCFrameworkの形式にすることが困難を極めました。というのもZOZOSUITの中にはOpenCVが使用されており、そのバージョンは3系であったためにXCFramework対応の入った4系へのアップデートが必要だったのです。また、ZOZOSUITのXCFranework化については上記とは別にビルドフェーズそのものを見直す必要もありました。

これらの理由から自分達のチームに収まらない範囲での対応が必要でした。加えて、全体としての方針でCarthageからCocoapodsへの移行も同時に進めていたこともあり、今回のタイミングではRosettaありでApple siliconへと移行することに決めました。こういった判断をあらかじめ行うことができたのも、検証機導入のおかげだと思います。

RosettaとXCFrameworks

Apple siliconではARM64というCPUのアーキテクチャを採用しましたが、Intel Macではx86_64というアーキテクチャが採用されていました。Rosettaとは、ARM64で動いているApple silicon下において、x86_64用のバイナリを動かすことができる翻訳プロセスになります。詳細は下記を参考にしてください。

developer.apple.com

では一体なぜ、XCFrameworkへの変更がApple siliconの導入に伴い必要なのかを説明します。Carthageはframeworkというファイルを生成し、それをプロジェクトへと入れています。Carthageによって生成されるframeworkファイルは複数のアーキテクチャに対応できるUniversal Binaryという仕組みを使用していました。

Intel Macでは、Simulator用にはx86_64のバイナリを作成し実機用にはARM64のバイナリを作成して、Universal Binaryとしています。一方でApple siliconではSimulatorもARM64として存在するため、Simulator、実機共にARM64向けのバイナリが必要になります。

Apple siliconの場合、2つの同じアーキテクチャ用のバイナリが発生してしまうため今までのUniversal Binaryの仕組みではうまくいきませんでした。

そこで登場したのが、XCFrameworksです。XCFrameworksは複数のframeworkをまとめることができるため同じARM64の向けのsimulator用と実機用のframeworkを共存させることができます。

しかし、ZOZOTOWNではCarthageで管理しているライブラリの全てをXCFramework化することが難しかったため(OpenCV 3系、ビルドフェーズの問題)先ほどあげたRosettaを使用しています。そのためx86_64向けのバイナリをRosettaを通すことによってARM64のsimulatorでも動かすことができるようになっているというわけです。 # 本導入 M1 Pro / M1 Max発表後にこのタイミングでチーム内の開発環境をApple siliconへと移行するのがベストであると判断しました。 理由は下記の通りです。 - M1を使用した検証や他社事例も含めだいぶ知見が溜まってきた - 明らかに開発効率を上げることができると確信できた
  • Intel版のMacBookの販売がなくなり、会社としてApple siliconを標準機にする方針になった
  • メンバーごとのマシンスペックによる開発効率の面や構成管理の整合の面からも開発環境を統一しておきたかった

MacBookのスペックについて

迷わずM1 Maxといきたいところですが、ZOZOではiOSエンジニアの支給端末をM1 Proとしました。スペックは下記の通りです。

  • 10コアCPUのM1 Pro
  • 32GBユニファイドメモリ
  • 1TB

M1 MaxではなくM1 Proの上記のスペックを選択した理由としては10コアCPUのM1 ProとM1 Maxとの違いとしてはGPUのみであり、ビルド時間には大きな影響がないこと。また、メモリが32GBか64GBでの違いもありますが、今までZOZOTOWNを開発していく中でメモリが不足するということはなかったため32GBでも問題なく動作するという判断をしました。

容量については複数のバージョンのXcodeを管理することもあるので、少し余裕を持たせて1TBとしています。Intel Macで動かしていて、CPU起因以外のマシンパワーの律速は発生していなかったというのも上記スペックを決める上で参考にしました。現状、上記のスペックで運用していて、スペックが不足していると感じたことはないです。

Apple silicon移行期間

2日間で移行完了

チーム内でスムーズに環境を変化させられるように、あらかじめ検証機で自分が詰まったところなどを全てメモしておきました。

上の画像のような手順として忘れがちな証明書周りまで、細かいことではあるのですが、意外と失念していることなどもあるのでまとめておくことで皆が詰まることなくスムーズに移行する手助けになります。こういった知見をあらかじめまとめたことにより、チーム全体で移行は2日間程度で完了できました。

検証機を用いて移行のためのPR作成

実は、チーム全体にApple siliconがくる1か月前に、検証機を用いてZOZOTOWNのアプリをApple siliconでも動かせるようにしたPRを作成しておきました。ここでも検証機が役に立ったのはいうまでもありません。手元にM1 Proが届いた段階で、チーム内で行うべきことは環境構築と動作確認のみという状態にできました。これもスムーズな移行のための施策です。

Intelプロセッサーを手放すタイミング

Apple siliconでビルドしたアプリをリリースした後に、問題が出ていないかを確認しました。このタイミングで、Intel Macから完全にApple siliconへと移行が完了しました。リグレッションが発生する可能性を考え、念のためまだ手元においてありますが、リリースから1か月半の間の運用の中で問題が出ていないためそろそろ手放し時だと思っています。参考までに自分達の場合、Apple silicon導入からIntel返却の期間は2か月を見込んでおきました。

効果

ここが、皆さんの一番気になるところだと思います。ビルド速度検証については下記ライブラリを使用しました。

github.com

今回はより正確な計測をするために、全てのケースにおいて下記の条件で統一しました。

  • リリースビルドでのビルド時間計測
  • 計測前にDerivedDataを削除する
Intel Core i9 Apple M1 Pro
6分25秒 3分12秒

Intel Macと比較すると、なんと半分までビルド時間を減らせました。ちなみに、Apple silicon導入後に複数ブランチが乱立するような大きなプロジェクトがあったのですが、このビルド時間の大幅短縮によってかなり効率が上がったことを体感できました。まだ導入を悩んでいる方がいましたら、Apple siliconの導入を強くお勧めします。

まとめ

いかがでしたでしょうか。Apple siliconの導入によってZOZOTOWNの開発がどのくらい向上したのかが少しでも伝わっていれば光栄です。ビルドフェーズを見直してビルド時間を短縮するのも大切ですが、Apple siliconの導入によってビルド時間を短縮させるのも1つの手なのかもしれません。

最後に

ZOZOでは、一緒に大規模なサービス作りをしてくれる方を募集しています。ご興味のある方は、以下のリンクから是非ご応募ください!

hrmos.co

Unityを組み込んだiOSアプリにおける、UXも考慮した開発

OGP

こんにちは、ZOZO NEXTで新規プロダクトの開発を担当している木下です。先日、3Dバーチャル試着に関する実証実験の取り組みが発表されました。3Dバーチャル試着ではユーザーが入力した体型データを基に3Dアバターが作成され、好みのアイテムを選んで着丈やサイズ感を確認できます。

zozonext.com

この実証実験のために開発したアプリは、Unity as a Library(UaaL)という技術を利用して実装されています。今回はUaaLをiOSアプリに組み込むにあたって工夫した点を、UX観点も交えながらご紹介します。

続きを読む

【オンラインMeetup イベントレポート】ZOZO Tech Talk #2 - iOS

f:id:ikenyal:20211206152923p:plain

こんにちは、ZOZO CTOブロックの池田(@ikenyal)です。

ZOZOでは、12/7にZOZO Tech Talk #2 - iOSを開催しました。 zozotech-inc.connpass.com

本イベントは、これまで夕刻に開催してきたMeetupとは異なり、ランチタイムに開催する「ZOZO Tech Talk」シリーズです。ZOZO Tech Talkでは、ZOZOがこれまで取り組んできた事例を紹介していきます。

そして、第2回はネイティブアプリ開発の中で、特にiOSにフォーカスし、弊社エンジニアがお話ししました。

登壇内容 まとめ

弊社の社員2名が登壇しました。

  • 歩みを振り返ると見えてくる 今、新たな「仲間」がWEARアプリ開発に必要な理由 (メディア開発本部 WEAR部 / 小野寺 賢)
  • 大公開!ZOZOTOWN iOSのコードレビューを覗きながらレビューの必要性を再確認しよう! (ZOZOTOWN開発本部 ZOZOTOWNアプリ部 / 松井 彩)



最後に

ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。

一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください!

corp.zozo.com

カテゴリー