Upgrade to Pro — share decks privately, control downloads, hide ads and more …

CLのウィジェット開発について

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers.

 CLのウィジェット開発について

CLではiOS17以上のユーザー向けにウィジェット機能を提供しています。 アプリ内でのウィジェットプレビューや、ローカライズなどの実装を行う上でいくつかの知見もありました。 ウィジェット機能の設計に触れながら、実装から得た知見などをお話しする予定です。

Avatar for CyberAgent

CyberAgent PRO

April 18, 2024
Tweet

More Decks by CyberAgent

Other Decks in Technology

Transcript

  1. 4

  2. 8

  3. ウィジェットの定義例 @available(iOSApplicationExtension 17.0, *) struct SmallWidget: Widget { let kind:

    String = "SmallWidget" init() { UIFont.loadCLSansBoldFont() } var body: some WidgetConfiguration { AppIntentConfiguration( kind: kind, provider: WidgetTimelineProvider<SmallWidgetConfigIntent>(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
  4. 状態別の出し分け実装例(Data) public enum WidgetTimelineData: Equatable, Sendable { /// 使い方紹介 case

    usage(UsageWidgetView.Mode) /// 日付 case date(DateWidgetDataSet) /// コミュニティ case community(CommunityWidgetDataSet) /// 読み込み中 case placeholder /// エラー case error(ErrorMessagePattern) } 実装のアプローチ 17
  5. 状態別の出し分け実装例(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
  6. 再掲 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<SmallWidgetConfigIntent>(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
  7. @available(iOSApplicationExtension 17.0, *) struct WidgetTimelineProvider<I: WidgetIntentProtocol>: 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<WidgetTimelineEntry> { ... } 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
  8. 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<WidgetTimelineEntry> { ... } 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
  9. 問題の解決まで 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
  10. 4. 表示前に、WidgetTimelineProviderの中でリサイズしてみる。 CLだとNukeを使用して画像読み込みをしているので、Nukeで用意された ImageProcessors.Resize を使用してみる。 5. クラッシュ kernel EXC_RESOURCE ->

    CyberLDHWidget[77604] exceeded mem limit: InactiveHard 30 MB (fatal) 今度はメモリ制限30MBに達してしまった。 確かに十分大きい元画像がメモリに乗った状態で、新たな小さい画像を作ろうとし ても画像2枚分のメモリを食うし、画像処理にもメモリを食いそう。 技術的知見とプラクティス 27
  11. 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
  12. 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
  13. // 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
  14. public protocol HasWidgetBackgroundStyle { var backgroundStyle: WidgetBackgroundStyle { get set

    } } public extension HasWidgetBackgroundStyle { func isBackgroundStyleValid() -> Bool { backgroundStyle != .unknown } } 実装上の挑戦と解決策 39
  15. 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
  16. インスタンスとファイルのライフサイクルの一致で状態不整合を減らせる 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