Slide 1

Slide 1 text

CLでのウィジェット開発から得た知見集 CL事業部 下村一将 2024/04/15  CA.swift #19 〜新規開発の技術選定〜 1

Slide 2

Slide 2 text

簡単に自己紹介 下村一将 (Kazumasa Shimomura / かず) CyberAgent 2019年新卒入社 入社時からCL事業部に所属 4月からCL iOSチームリーダーに SNS X: _kzumu GitHub: s2mr 2

Slide 3

Slide 3 text

CLとは LDH所属グループ・アーティストのライブキャスト動画やMVを視聴できるサービス 動画以外にもSNS機能やフォトコレクション機能など、アーティストを楽しむための機 能が満載! iOSチームでは、マルチモジュールでの開発やビルド時間の改善、Visual Regression Testなど力を入れています!! 3

Slide 4

Slide 4 text

4

Slide 5

Slide 5 text

アジェンダ 開発の背景と目的 開発したウィジェットのUIなど ウィジェットAPIの外観 ウィジェット実装上の制約 実装のアプローチ 技術的知見やプラクティス 5

Slide 6

Slide 6 text

はじめに ウィジェット開発の背景と目的 アプリの起動頻度の向上 ウィジェット作成による新規会員登録数の増加 CL独自のR&D施策から発案。エンジニアドリブン iOS17以上のサポートが実現 6

Slide 7

Slide 7 text

開発したウィジェット一覧 コミュニティ 番組 ライブキャスト配信 日付 バッテリー 記念日/イベント 7

Slide 8

Slide 8 text

8

Slide 9

Slide 9 text

ウィジェットAPIの概観 従来のAPI(iOS16未満) 構成可能なウィジェットを作成するためには intentdefinition ファイルを作成し て、GUIで定義する必要があった。 intentdefinition ファイルからビルド時にswiftファイルが内部的に生成される 9

Slide 10

Slide 10 text

ウィジェットAPIの概観 以下APIはWidgetに限らずSiriKitなどでもエンティティを扱うための汎用的な型定義。 iOS16+ 【AppIntents】AppEntity ホーム画面からシステム提供のダイアログ内で扱うEntityを定義するための protocol。 【AppIntents】EntityQuery そのダイアログ内で表示するAppEntity AppIntents アプリがシステムとの連携を行うためのフレームワーク。Widget,Siri,ショートカットアプ リなど。 10

Slide 11

Slide 11 text

ウィジェットAPIの概観 iOS17からは構成可能なウィジェットでもSwiftオンリーで開 発できるように! iOS17+ 【WidgetKit】AppIntentConfiguration Widgetの設定を行うためのstruct 【WidgetKit】AppIntentTimelineProvider Widgetの表示情報を管理するためのprotocol。 snapshot, timelineの概念 【AppIntents】WidgetConfigurationIntent 11

Slide 12

Slide 12 text

ウィジェットの定義例 @available(iOSApplicationExtension 17.0, *) struct SmallWidget: Widget { let kind: String = "SmallWidget" init() { UIFont.loadCLSansBoldFont() } var body: some WidgetConfiguration { AppIntentConfiguration( kind: kind, provider: WidgetTimelineProvider(dependency: .default()) ) { entry in WidgetRootView( entry: entry, widgetURLResolver: WidgetURLResolver(baseURL: URL.service), imageDownloader: nil ) } .configurationDisplayName(Text(L10n.Widget.displayName)) .description(Text(L10n.Widget.Description.small)) .supportedFamilies([.systemSmall]) } } 今回は仕様の都合上サイズごとにWidgetを定義していますが、機能ごとにWidgetを宣言して まとめてサイズを定義することも可能です。 12

Slide 13

Slide 13 text

ウィジェット実装上の制約 リロード回数の制約 ユーザーが頻繁に確認するウィジェットの場合、通常、1日単位のバジェットに40〜70 回の更新が含まれます。この割合は、概算で15〜60分おきにウィジェットが再読み込み されることを意味しますが、一般的に、関連する多くの要因によってこの間隔は変化し ます。 https://developer.apple.com/jp/documentation/widgetkit/keeping-a-widget-up-to-date 30MBのメモリ制限 重い画像やデータの扱いには注意する。 重い処理でも制限に達することも。 CLでもこの問題に当たったので後述。 ウィジェット実装上の制約 13

Slide 14

Slide 14 text

異なるBundleID間でのデータの共有 Widget側からアプリ側のデータは参照できないので、AppGroup, Keychain Sharingを使用し てデータにアクセスする必要がある。 ファイルの共有に関してはAppGroupを使用する UserDefaults, CoreData FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "yourAppGroupIdentifier") Keychainの共有に関してはKeychain Sharingの方式を選択 // KeychainAccess の場合 Keychain(service: "ServiceName") ウィジェット実装上の制約 14

Slide 15

Slide 15 text

実装のアプローチ UIのサイズごとの出し分け 各Viewの中で以下の記述ができるので、それによってUIの切り替えができる。 e.g. systemSmallのときは〜、systemMediumのときは〜 @Environment(\.widgetFamily) fileprivate var widgetFamily 実装のアプローチ 15

Slide 16

Slide 16 text

状態別の出し分け 対応ウィジェットの種類は6種類 Intent未設定のときの使い方紹介UI(HOME, ギャラリー) エラー時(エラー文言の切り替え) 読込中のプレースホルダ 大まかに分けても9種類も存在! そのため、状態はすべてenumで管理し、WidgetRootViewにデータを渡せば簡単にUIを切り 替えられるように 実装のアプローチ 16

Slide 17

Slide 17 text

状態別の出し分け実装例(Data) public enum WidgetTimelineData: Equatable, Sendable { /// 使い方紹介 case usage(UsageWidgetView.Mode) /// 日付 case date(DateWidgetDataSet) /// コミュニティ case community(CommunityWidgetDataSet) /// 読み込み中 case placeholder /// エラー case error(ErrorMessagePattern) } 実装のアプローチ 17

Slide 18

Slide 18 text

状態別の出し分け実装例(View) public struct WidgetRootView: View { public var entry: WidgetTimelineEntry public var widgetURLResolver: WidgetURLResolverProtocol public var imageDownloader: ImageDownloadable? public init(...) { ... } public var body: some View { switchWidgetView() .environment(\.colorScheme, entry.selectedColorScheme) } @ViewBuilder private func switchWidgetView() -> some View { switch entry.data { case .usage(let mode): UsageWidgetView(mode: mode) case .date(let dataSet): DateWidgetView(dataSet: dataSet, imageDownloader: imageDownloader) case .community(let dataSet): CommunityWidgetView(dataSet: dataSet, imageDownloader: imageDownloader) } } } 実装のアプローチ 18

Slide 19

Slide 19 text

再掲 WidgetTimelineProvider内で表示したいデータを含むentryを返却さえすれば、任意のViewが 簡単に表示できる @available(iOSApplicationExtension 17.0, *) struct SmallWidget: Widget { let kind: String = "SmallWidget" init() { UIFont.loadCLSansBoldFont() } var body: some WidgetConfiguration { AppIntentConfiguration( kind: kind, provider: WidgetTimelineProvider(dependency: .default()) ) { entry in WidgetRootView( entry: entry, widgetURLResolver: WidgetURLResolver(baseURL: URL.service), imageDownloader: nil ) } .configurationDisplayName(Text(L10n.Widget.displayName)) .description(Text(L10n.Widget.Description.small)) .supportedFamilies([.systemSmall]) } } 実装のアプローチ 19

Slide 20

Slide 20 text

データ取得の構造化 異なる機能をもつWidgetを1ウィジェットとして扱う影響で、WidgetKitのTimelineProvider も1つのみ定義する感じになる。 そこで、 AppIntentTimelineProvider に準拠したWidgetTimelineProviderを親として定 義して、 各ウィジェット用のTimelineProviderを保持するツリー構造でのアプローチ。 実装のアプローチ 20

Slide 21

Slide 21 text

@available(iOSApplicationExtension 17.0, *) struct WidgetTimelineProvider: AppIntentTimelineProvider { struct Dependency { var timelineProviderSwitcher: TimelineProviderSwitcher var dateGenerator: DateGenerator static func `default`() -> Dependency { Dependency( timelineProviderSwitcher: TimelineProviderSwitcher(...), dateGenerator: CurrentDateGenerator() ) } } private let dependency: Dependency func placeholder(in _: Context) -> WidgetTimelineEntry { dependency.timelineProviderSwitcher.placeholder() } func timeline(for configuration: Intent, in context: Context) async -> Timeline { ... } func snapshot(for configuration: Intent, in context: Context) async -> WidgetTimelineEntry { guard let userWidget = configuration.widgetSelection?.userWidget else { return WidgetTimelineEntry( date: dependency.dateGenerator.date, data: .usage(context.isPreview ? .gallery : .home), selectedColorScheme: .light ) } return await dependency.timelineProviderSwitcher.snapshot(for: userWidget, context: context) } } 実装のアプローチ 21

Slide 22

Slide 22 text

public struct TimelineProviderSwitcher { // Widget の種類ごとのデータ読み込みを行うstruct private let dateTimelineProvider: DateTimelineProvider private let communityTimelineProvider: CommunityTimelineProvider private let batteryTimelineProvider: BatteryTimelineProvider public init( ... ) { ... } public func placeholder() -> WidgetTimelineEntry { ... } public func getTimeline(for userWidget: UserWidget, context: TimelineProviderContextProtocol) async -> Timeline { ... } public func snapshot(for userWidget: UserWidget, context: TimelineProviderContextProtocol) async -> WidgetTimelineEntry { switch userWidget.widgetType { case .date: return await dateTimelineProvider.getSnapshot(for: userWidget, context: context) case .community: return await communityTimelineProvider.getSnapshot(for: userWidget, context: context) case .battery: return await batteryTimelineProvider.getSnapshot(for: userWidget, context: context) case .unknown: return WidgetTimelineEntry(date: Date(), data: .placeholder, selectedColorScheme: .dark) } } } 実装のアプローチ 22

Slide 23

Slide 23 text

技術的知見とプラクティス 技術的知見とプラクティス 23

Slide 24

Slide 24 text

プラクティス1:デバッグテクニック WidgetのログはXcode上だと不安定で見れない場合があるので、Console.appを使用す る OS_ACTIVITY_MODE: disabled は解除する必要 プロセス名(おそらくWidgetのScheme名)でフィルターする 技術的知見とプラクティス 24

Slide 25

Slide 25 text

プラクティス2:メモリの30MB制限と、ピクセル総数によるバリデー ション ユーザーが画像を設定できるタイプのウィジェットで遭遇 シミュレータに入っているデフォの画像だと発生せず、物理端末で撮影した画像だと発 生。 問題発生までのフロー 1. ユーザーがウィジェット作成時に画像を設定して、画像を1MBのjpegに圧縮したものを AppGroup内のディレクトリに保存。 2. ウィジェットを配置しても、読み込み中のままでしばらく待っても何も表示されない 3. クラッシュしていた 技術的知見とプラクティス 25

Slide 26

Slide 26 text

問題の解決まで 1. Console.appでデバッグ 2. エラーを見つける Widget archival failed due to image being too large [9] - (2848, 2846), totalArea: 8105408 > max[5193849.600000] Request ended for SmallWidget:systemSmall:148558229597663593 - error: WidgetKit.WidgetArchiver.ArchivingError.imageTooLarge(size: (2848.0, 2846.0), maximumSize: (1548.0, 3355.2)) 設定された画像がピクセル総数、ピクセル数のシステム制限に引っかかっている。 (おそらくundocumented) 3. maximumSizeの記載があるので引っかからないようにすれば良さそう 技術的知見とプラクティス 26

Slide 27

Slide 27 text

4. 表示前に、WidgetTimelineProviderの中でリサイズしてみる。 CLだとNukeを使用して画像読み込みをしているので、Nukeで用意された ImageProcessors.Resize を使用してみる。 5. クラッシュ kernel EXC_RESOURCE -> CyberLDHWidget[77604] exceeded mem limit: InactiveHard 30 MB (fatal) 今度はメモリ制限30MBに達してしまった。 確かに十分大きい元画像がメモリに乗った状態で、新たな小さい画像を作ろうとし ても画像2枚分のメモリを食うし、画像処理にもメモリを食いそう。 技術的知見とプラクティス 27

Slide 28

Slide 28 text

6. 別のアプローチとして、省メモリな画像リサイズの方法を試してみる。 https://swiftsenpai.com/development/reduce-uiimage-memory-footprint/ このサイトによると... Memory Usage ≠ File Size Memory use is related to the dimensions of the image, not the file size. Typically, 1 pixel of a decoded image will take up 4 bytes of memory — 1 byte for red, 1 byte for green, 1 byte for blue, and 1 byte for the alpha component. (3648 * 5472) * 4 bytes ≈ 80MB CLの例だと、正方形にトリミングして保存していたため (2848 * 2846) * 4 bytes ≈ 30.9MB 惜しい!だから成功する場合としない場合があったのか... 技術的知見とプラクティス 28

Slide 29

Slide 29 text

6. CGImageSourceCreateThumbnailAtIndex を使用する。 この方法だとメモリに乗る前にURLから直接リサイズ後の画像を取得できるので省メモリ になる TimelineProviderのcontext経由で、ウィジェットのdisplaySizeが取得できるのでそれと displayScaleを掛けて使用する。 displayScaleそのままだとメモリ制限を超えてしまうため * 0.7 技術的知見とプラクティス 29

Slide 30

Slide 30 text

func downloadWidgetImage( with url: URL?, size: ImageSize? = nil, context: TimelineProviderContextProtocol ) async -> UIImage? { guard let url else { return await downloadImage(with: url, size: size) } let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, imageSourceOptions) else { return nil } let displayScale = context.displayScale ?? 1 let maxDimensionInPixels = min(context.displaySize.width, context.displaySize.height) * displayScale * 0.7 let downsampleOptions = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceShouldCacheImmediately: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels ] as CFDictionary guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions) else { return nil } return UIImage(cgImage: downsampledImage) } 技術的知見とプラクティス 30

Slide 31

Slide 31 text

7. 無事に正常に画像の表示ができた ポイント 画像のピクセル総数によるバリデーション ウィジェットの表示サイズに応じて画像をリサイズする必要があった 実行時のメモリ使用量による制限(30MB) メモリに載せるデータはできる限り小さくする 技術的知見とプラクティス 31

Slide 32

Slide 32 text

実装上の挑戦と解決策 実装上の挑戦と解決策 32

Slide 33

Slide 33 text

画像の読み込み @State などを使用して動的にViewを切り替えることはできないので、Providerの方で 事前に画像を読み込む必要がある。 CustomのImageViewを作成し、 enum ImageSource を渡して動的、静的に画像読み込 みを切り替えられるように。 WebViewなどUIKitのものを使うこともできない 実装上の挑戦と解決策 33

Slide 34

Slide 34 text

ローカライズの挑戦 SwiftUIのプレビューで動的にローカライズを変更するためには LocalizedStringResource を使用する必要がある。 CLではSwiftGenを使用してタイプセーフに扱っていたが、この形式には対応していない ため自前でStencilのテンプレートを記述した。 実装上の挑戦と解決策 34

Slide 35

Slide 35 text

アプリ内ウィジェットプレビューの実装 ウィジェット自体の表示に加えて、アプリ内でもウィジェットのプレビューを行う仕 様。 TimelineProviderSwitcherを共通フレームワークに配置して、同様の処理で表示できる ように。 WidgetSizeなどはダミーの値を入れて、アプリ内では使わないように。 実装上の挑戦と解決策 35

Slide 36

Slide 36 text

ウィジェットの宣言方法 WidgetBundle で複数ウィジェットを束ねるやり方は静的なウィジェットにしか合わ ず、今回のユーザーが動的にウィジェットを作成するような仕様にはマッチしない。 そのため、Widgetの定義はS, M, Lの3つを行い、ユーザーの作成によって動的に増 えるウィジェットはIntentから選べるように。 実装上の挑戦と解決策 36

Slide 37

Slide 37 text

プロトコルによるフォーム変更の設計と実装 ウィジェットによって設定できる項目は違うが、ロジック集約の観点で1画面でフォー ムを切り替えたかった。 ウィジェットによって設定できる項目が重複しているケースもある。 e.g. キャスウィジェットと番組ウィジェットはカラーテーマの設定ができる structで定義されたウィジェットパラメータがProtocolに準拠しているかで、ウィジェットの 設定項目を判定 実装上の挑戦と解決策 37

Slide 38

Slide 38 text

// WidgetEditorContent.swift private func makeFormSection() -> some View { VStack(alignment: .leading, spacing: 32) { WidgetNameForm(widgetName: .init( get: { dataModel.editorParameter.widgetName }, set: { event(.setWidgetName($0)) } )) if let parameter = dataModel.editorParameter as? HasWidgetArtistDestination { ArtistDestinationForm( selectedArtist: dataModel.cachedArtists[parameter.destinationArtistID], changeButtonSelected: { event(.selectChangeArtistDestination) } ) } if let parameter = dataModel.editorParameter as? HasWidgetBackgroundStyle { BackgroundStyleSelectionForm( selectedBackgroundStyle: parameter.backgroundStyle ) { style in event(.selectWidgetBackgroundStyle(style)) } } ... } } 実装上の挑戦と解決策 38

Slide 39

Slide 39 text

public protocol HasWidgetBackgroundStyle { var backgroundStyle: WidgetBackgroundStyle { get set } } public extension HasWidgetBackgroundStyle { func isBackgroundStyleValid() -> Bool { backgroundStyle != .unknown } } 実装上の挑戦と解決策 39

Slide 40

Slide 40 text

public struct WidgetEditorCastParameter: WidgetEditorParameterProtocol & HasWidgetBackgroundStyle { public static let widgetType = WidgetType.cast public static func makeForCreate() -> Self { Self(widgetName: "", backgroundStyle: .dark) } public static func makeForEdit(userWidget: UserWidget) -> Self? { guard let castWidget = userWidget.castWidget else { return nil } return Self( widgetName: userWidget.name, backgroundStyle: castWidget.backgroundStyle ) } public var widgetName: String public var backgroundStyle: WidgetBackgroundStyle public init(...) { ... } public func isParameterValid() -> Bool { isWidgetNameValid() && isBackgroundStyleValid() } } 実装上の挑戦と解決策 40

Slide 41

Slide 41 text

一時画像の扱いについて 画像を選択してからユーザーが保存ボタンを押すまでの間、AppGroup内のフォルダに は入れずに一時フォルダにおいておきたかった。 (保存しないでアプリキルした場合など にどんどん無駄な画像が溜まっていくため) 画面が非表示になったタイミングでもれなく一時ファイルは削除したかったのでdeinitで ファイルを削除する構造にした。 (アプリキル時は残ってしまうので、tmpフォルダのシステムクリーンアップのタイミン グに任せる) 実装上の挑戦と解決策 41

Slide 42

Slide 42 text

インスタンスとファイルのライフサイクルの一致で状態不整合を減らせる public final class TemporaryFile { public let url: URL private let fileManager: FileManagerProtocol public init(url: URL, fileManager: FileManagerProtocol) { self.url = url self.fileManager = fileManager } deinit { do { guard fileManager.fileExists(atPath: url.path) else { return Log.info("Already file deleted. at: " + url.path) } try fileManager.removeItem(at: url) Log.info("Temporary file deleted. at: " + url.path) } catch { Log.error(error) } } } 実装上の挑戦と解決策 42

Slide 43

Slide 43 text

まとめ Widget開発を通して、様々なプラクティスを得ることができました。 様々な技術ブログや、レビューいただいたiOSチームの皆さんに感謝申し上げます。 最後までご静聴頂きありがとうございました。 実装上の挑戦と解決策 43