本文是笔者参与 2023 年 4 月 20 日 “SwiftUI 技能沙龙( 北京站 )” 活动的共享内容。依据回忆收拾而成。有关本次活动的状况,能够参看 我在北京参与 SwiftUI 技能沙龙 一文。

本次活动选用的是线下沟通并辅以 live coding 的方式,因而内容的侧重点以及安排方式与以往的博客文章会有明显的不同。

原文发表在我的博客wwww.fatbobman.com

欢迎订阅我的大众号:【肘子的Swift记事本】

开场白

咱们好,我是肘子。今日我要和咱们沟通的主题是 —— 打造可适配多渠道的 SwiftUI 运用。

电影猎手

咱们先看一个比如,然后再进入今日的正题。

打造可适配多平台的 SwiftUI 应用

这是我为本次沟通主题写的一个 Demo 运用 —— “电影猎手”。100% 依据 SwiftUI 开发,目前支撑三个渠道: iPhone、iPad 和 macOS。

运用者能够经过它来浏览电影信息,包括正在上映以及即将上映的影片。而且能够依据口碑、评分、盛行度、电影类型等维度检查想要了解的影片。

“电影猎手” 是一个专门为本次沟通会预备的 Demo,因而只完结了有必要的部分。

相较于 iPhone 版别,iPad 版别除了为了利用更大的屏幕空间对布局做出了一定的调整外,还提供了多窗口运转的才能,运用者能够在每个窗口中独立进行操作。

打造可适配多平台的 SwiftUI 应用

mac 版别进行了更多契合 macOS 风格的适配,例如:运用了契合 mac 标准的设置视图、支撑指针悬浮响应、菜单栏图标,而且支撑创立新窗口并直接跳转到特定电影类别(依据数据驱动的 WindowGroup)。

打造可适配多平台的 SwiftUI 应用

受限于时刻,本次沟通中,咱们不会对该运用的完整适配进程进行讨论,而是就两个我个人认为比较重要但又简略忽视的点进行沟通。

兼容性

与不少跨渠道结构所推崇的“Write once, run anywhere”不同,苹果对 SwiftUI 的定位是“Learn once, apply anywhere”。

个人了解,SwiftUI 更像是一种编程哲学,把握了它,便具有了很长一段时刻内在苹果生态的不同渠道上进行开发的才能。从另一个视点来看,用 SwiftUI 编写的代码,虽然大部分能够运转在不同的渠道上,但有一部分则只能运转在特定渠道上,而且往往这部分有渠道限定的功能,最能表现渠道所具有的特征和优势。

SwiftUI 经过设定了某些兼容性的约束,促进开发者在做多渠道适配时,不得不考虑渠道特征的不同,并依据这些不同来做有针对性的调整。

可是,假如开发者不能了解 SwiftUI 的这个“约束”,并提早做一些预备作业,或许会为之后的多渠道开发作业带来一些隐患和添加不用要的作业量。

以“电影猎手”的 iPad 版别为例。在 iPad 中,运用者能够调整运用的窗口尺度。为了让布局更贴合当时的窗口状况,咱们一般会在视图中运用环境值来进行判断:

@Environment(\.horizontalSizeClass) var sizeClass

依据 sizeClass 的当时状况,是 compact(紧凑)还是 regular(常规),来动态调整布局。

假如你的运用只计划适配 iPadOS,这样做是完全正确的。可是关于“电影猎手”这个运用来说,因为之后还需求适配 macOS 版别,运用这种办法便会呈现问题。

因为 horizontalSizeClass 这个环境值无法在 macOS 中运用,UserInterfaceSizeClass 是 iOS(iPadOS)独有的概念。咱们在视图代码中依靠这个环境值越多,将来需求做的调整也就越多。

打造可适配多平台的 SwiftUI 应用

为了防止在适配其他渠道时重复调整代码,咱们能够选用类似于 horizontalSizeClass 的方式(经过环境变量),创立一个可用于所有需求适配渠道的自定义环境变量来处理这个问题。

首要创立一个 DeviceStatus 枚举类型:

public enum DeviceStatus: String {
  case macOS
  case compact
  case regular
}

在这个枚举类型中,除了 iOS 中呈现的两种窗口状况外,咱们还添加了 macOS 枚举项。

然后,创立类型为 DeviceStatus 的环境值:

struct DeviceStatusKey: EnvironmentKey {
  #if os(macOS)
    static var defaultValue: DeviceStatus = .macOS
  #else
    static var defaultValue: DeviceStatus = .compact
  #endif
}
public extension EnvironmentValues {
  var deviceStatus: DeviceStatus {
    get { self[DeviceStatusKey.self] }
    set { self[DeviceStatusKey.self] = newValue }
  }
}

经过条件编译句子 #if os(macOS) ,在 macOS 中,环境值被设置为对应的选项。咱们还需求创立一个 View Modifier( 视图润饰器 ),以便能够在 iOS 中及时了解当时的窗口状况:

#if os(iOS)
  struct GetSizeClassModifier: ViewModifier {
    @Environment(\.horizontalSizeClass) private var sizeClass
    @State var currentSizeClass: DeviceStatus = .compact
    func body(content: Content) -> some View {
      content
        .task(id: sizeClass) {
          if let sizeClass {
            switch sizeClass {
            case .compact:
              currentSizeClass = .compact
            case .regular:
              currentSizeClass = .regular
            default:
              currentSizeClass = .compact
            }
          }
        }
        .environment(\.deviceStatus, currentSizeClass)
    }
  }
#endif

当视图的 horizontalSizeClass 发生改变时,及时的更新咱们自定义的 deviceStatus。最终再经过一个 View Extension,将不同渠道的代码组合在一起:

public extension View {
  @ViewBuilder
  func setDeviceStatus() -> some View {
    self
    #if os(macOS)
    .environment(\.deviceStatus, .macOS)
    #else
    .modifier(GetSizeClassModifier())
    #endif
  }
}

将 setDeviceStatus 运用在根视图上:

ContentView:View {
    var body:some View {
      RootView()
          .setDeviceStatus()
    }
}

至此,咱们便拥有了在 iPhone、iPad 以及 macOS 中了解当时窗口状况的才能。

@Environment(\.deviceStatus) private var deviceStatus

假如将来,咱们需求适配更多的渠道,只需求调整自定义环境值的设定便能够了。虽然仍需求调整视图代码,但相较于 horizontalSizeClass 来说,修正量将削减许多。

setDeviceStatus 并非只能用于根视图,但至少应该运用在当时运用的最宽视图处。这是因为 horizontalSizeClass 只表明当时视图的横向尺度类别,也就是说,假如在一个横向尺度被限定的视图中( 例如 NavigationSplitView 的 Sidebar 视图 )获取 horizontalSizeClass ,无论运用的窗口尺度怎么,当时视图的 sizeClass 只能为 compact。 咱们创立 deviceStatus 的意图是用来观察当时运用的窗口状况,故此有必要运用于最宽处。

在 SwiftUI 中,除了环境值外,另一个具有较多渠道“约束”的部分就是视图的 Modifier。

例如,在预备开端适配“电影猎手”的 macOS 版别时(已完结 iPad 版别的适配),当添加好 macOS 的 destination 并进行编译后,你会发现 Xcode 呈现了不少类似下面这种过错:

打造可适配多平台的 SwiftUI 应用

这是因为某些 View Modifier 并不支撑 macOS。关于上面的这个过错提示,咱们能够简略地运用条件编译句子将其屏蔽掉。

#if !os(macOS)
    .navigationBarTitleDisplayMode(.inline)
#endif

不过,假如类似的问题许多,咱们无妨选用一个一劳永逸的计划。

在“电影猎手”中,navigationBarTitleDisplayMode 是一个经常被运用到的 Modifier ,咱们能够创立一个 View Extension 来处理不同渠道下的兼容性问题:

enum MyTitleDisplayMode {
    case automatic
    case inline
    case large
    #if !os(macOS)
        var titleDisplayMode: NavigationBarItem.TitleDisplayMode {
            switch self {
            case .automatic:
                return .automatic
            case .inline:
                return .inline
            case .large:
                return .large
            }
        }
    #endif
}
extension View {
    @ViewBuilder
    func safeNavigationBarTitleDisplayMode(_ displayMode: MyTitleDisplayMode) -> some View {
        #if os(iOS)
            navigationBarTitleDisplayMode(displayMode.titleDisplayMode)
        #else
            self
        #endif
    }
}

在视图中直接运用:

.safeNavigationBarTitleDisplayMode(.inline)

假如你计划将运用引进更多的渠道,提早预备一些处理兼容性的代码将会极大地改善之后的开发效率。这种做法不仅能够处理跨渠道兼容性问题,还有其他优点:

  • 能够改善视图中代码的整齐度(削减条件编译句子的运用)
  • 能够改善 SwiftUI 在不同版别之间的兼容性

当然,要创立并运用这类代码,条件是开发者有必要现已对 SwiftUI 在不同渠道中的“约束”( 每个渠道的特征、优势、处理方式 )有了比较清晰的知道。盲目地运用这些处理兼容性的代码或许会破坏 SwiftUI 创立者的苦心,让开发者无法精确地表现不同渠道的特征。

数据源

聊完兼容性后,咱们再聊另一个在构建多渠道运用初期简略忽略的问题:数据源(数据依靠)。

当咱们将“电影猎手”从 iPhone 移植到 iPad 或 Mac 上时,除了屏幕可用空间更大之外,另一个明显的改变是运用者能够同时翻开多个窗口,并能够在不同的窗口中对“电影猎手”进行独立的操作。

但是,假如咱们直接将尚未进行多屏适配的 iPhone 版别的“电影猎手”运转于 iPad 上,会发现虽然能够同时敞开多个“电影猎手”窗口,但所有的操作都是同步的,也就是在一个窗口中进行的操作同时会表现在另一个窗口中。这样就失去了多窗口存在的意义。

打造可适配多平台的 SwiftUI 应用

为什么会呈现这种状况呢?

咱们都知道 SwiftUI 是一个声明式结构。这不仅意味着开发者能够经过声明的方式来结构视图,而且场景(对应着独立的窗口)乃至整个 App 都是依据声明式代码来创立的。

@main
struct MovieHunterApp: App {
    @StateObject private var store = Store()
    var body: some Scene {
        WindowGroup {
            ContentView()
               .environmentObject(store)
        }
    }
}

在 Xcode 创立的 SwiftUI 项目模板中,WindowGroup 对应着一个场景声明。因为 iPhone 只支撑单窗口方式,一般咱们不会太注意它的存在,但在 iPadOS 以及 macOS 这些支撑多窗口的体系中,则代表着,每次创立一个新窗口(在 macOS 中,经过菜单中的新建来创立新窗口),都将严格地依照 WindowGroup 的声明来进行。

在“电影猎手”中,咱们在 App 的位置创立了 Store(保存运用状况以及主要处理逻辑的单元)的实例,并经过 .environmentObject(store) 注入到根视图中。这种经过 environmentObjectenvironment 来注入的信息,只能在为当时场景创立的视图树中被运用。

打造可适配多平台的 SwiftUI 应用

虽然体系在创立新场景(新窗口)时会为其创立一棵新的视图树,但因为为新场景的根视图注入的仍然是同一个 Store 实例,因而虽然场景不同,但在不同的窗口中获取的运用状况完全一致。

打造可适配多平台的 SwiftUI 应用

因为“电影猎手”选用了编程式导航,视图仓库以及 TabView 的状况都保存在 Store 中,因而会呈现操作同步的状况。

因而,假如咱们计划将运用引进到一个支撑多窗口渠道的时分,最好能提早考虑到这种状况,想好怎么安排运用的状况。

关于“电影猎手”当时的状况配置来说,咱们能够经过将创立 Store 实例的位置移动到场景内来处理上述问题(将 MovieHunterApp 中与 Store 有关的代码移动到 ContentView 中)。

打造可适配多平台的 SwiftUI 应用

打造可适配多平台的 SwiftUI 应用

不过,这种在每个场景中创立独立的 Store 实例的方式并非适用于所有状况。在许多状况下,开发者只想在运用中保持一个 Store 实例。我将经过另一个简略的运用来展现这种场景。

我想许多读者此刻都不会太赞同在每个场景中创立一个独立的 Store 实例这种做法。至于这种做法是否正确、是否契合当时盛行的 Single source of truth 的理念,咱们在之后还会继续讨论。

这是一个极为简略的 Demo —— SingleStoreDemo。它只有一个 Store 实例并支撑多窗口,运用者在每个窗口中都能够独登时切换 TabView,而且 TabView 的状况由唯一的 Store 实例持有。经过点击恣意窗口中恣意 Tab 中的 “Hit Me” 按钮来添加点击次数。点击次数显现在窗口的上方。

打造可适配多平台的 SwiftUI 应用

咱们在规划这个 App 的状况时,就要考虑到哪些是运用大局的状况,哪些是仅限于当时场景(窗口)的状况。

struct AppReducer: ReducerProtocol {
    struct State: Equatable {
        var sceneStates: IdentifiedArrayOf<SceneReducer.State> = .init()
        var hitCount = 0
    }
}
struct SceneReducer: ReducerProtocol {
    struct State: Equatable, Identifiable {
        let id: UUID
        var tabSelection: Tab = .tab1
    }
}

在运用的总 State 中,除了服务于大局的 hitCount 外,咱们还为或许的多场景需求将场景的 State 独立出来。并经过 IdentifiedArray 来办理不同场景的 State。

当一个场景被创立后,经过 onAppear 里的代码,在 App State 中创立归于它自己的 State 数据,并在场景被删去时,经过 onDisappear 里的代码,将当时场景的 State 清除去。

.onAppear {
    viewStore.send(.createNewScene(sceneID)) // create new scene state
}
.onDisappear {
    viewStore.send(.deleteScene(sceneID)) // delete current scene state
}

如此一来,便完结了经过一个 Store 实例,支撑多窗口独立操作的需求。

概况,请自行检查 代码

在这里需求特别注意的是,不知道出于什么原因(或许与随机数的种子有关),经过同一个场景声明创立的根视图,假如运用@State 创立的 UUID 或随机数,即便在不同的窗口中,即便窗口创立的时刻不同,UUID 或随机数的值是完全相同的。如此一来,便无法为不同的场景创立不同的状况集(当时的场景状况运用 UUID 作为标识符)。为了防止这种状况,需求在 onAppear 中重新生成新的 UUID 或随机数。

.onAppear {
    sceneID = UUID()
    ...
}

这个问题,同样呈现在“电影猎手”中创立 overlayContainer 的场景中( 用于显现全屏电影剧照 ),也是选用上述的办法才得以处理。

虽然 SingleStoreDemo 运用 TCA 作为数据流结构,但这并不代表 TCA 在完结类似需求时有特别的优势。在 SwiftUI 中,只要了解了状况、声明和响应之间的关系,开发者就能够用任何想用的方式来安排数据。无论是将状况进行统一办理,还是涣散在不同的视图中,都有各自的优势和意义。此外,SwiftUI 自身还为开发者提供了不少专门用于处理多场景方式下的特点包装器类型,例如:@AppStorage、@SceneStorage、@FocusedSceneValue、@FocusedSceneObject 等。

回过头来,咱们再看一下“电影猎手”的多个 Store 实例的完结方式。莫非“电影猎手”没有运用层面(大局)的状况需求吗?

当然不是。在“电影猎手”中,运用层面的大多数状况是由 @AppStorage 来办理的,而别的一些大局状况,则是经过 Core Data 来进行维护。也就是说,虽然“电影猎手”选用了为每个场景创立一个独立的 Store 实例的外在方式,但在底层逻辑上,与 SingleStore 的 TCA 完结本质上没有什么不同。

我认为,开发者应依据需求选用适宜的手段,而不用拘泥于某种特定的数据流理论或结构。

最终,咱们来谈谈在将“电影猎手”适配到 macOS 时,碰到的别的一个与数据源有关的问题。

为了让“电影猎手”更契合 macOS 运用的标准,咱们将视图移动到菜单项中,并在 mac 代码中取消了 TabView。

@main
struct MovieHunterApp: App {
    let stack = CoreDataStack.share
    @StateObject private var store = Store()
    var body: some Scene {
        WindowGroup {
         ...
        }
        #if os(macOS)
            Settings {
                SettingContainer() // 声明设置视图
            }
        #endif
    }
}
// ContentView
VStack {
    #if !os(macOS)
        TabViewContainer()
    #else
        StackContainer()
    #endif
}

当做完这些改动后,您会发现,咱们只能在设置中更改电影信息窗口的色彩方式和言语,而设置视图并不会像 iPhone 和 iPad 那样同时随之改变。

打造可适配多平台的 SwiftUI 应用

这是因为,在 macOS 中,运用 Settings 来声明 Settings 窗口同样是创立了一个新的场景,会创立一棵独立的视图树。在 iOS 中,咱们经过在根视图( ContentView )中修正环境值的方式来更改色彩和言语,并不会对 macOS 的 Settings 场景产生影响。因而,在 macOS 中,咱们需求单独为 Settings 视图来调整色彩和言语的环境值。

struct SettingContainer: View {
    @StateObject var configuration = AppConfiguration.share
    @State private var visibility: NavigationSplitViewVisibility = .doubleColumn
    var body: some View {
        NavigationSplitView(columnVisibility: $visibility) {
          ...
        } detail: {
           ...
        }
        #if os(macOS)
        .preferredColorScheme(configuration.colorScheme.colorScheme)
        .environment(\.locale, configuration.appLanguage.locale)
        #endif
    }
}

恰恰是因为选用了 @AppStorage 来办理全域状况,才能在不引进 Store 实例的状况下,轻松地完结设置窗口的适配作业。

总结

相较于为不同的渠道调整视图布局,今日说到的问题并没那么起眼,简略忽视。

但是,只要了解这些关键的存在,并提早进行规划和预备,适配的进程就会愈加顺利。开发者也就能够把更多精力投入到为用户打造不同渠道的独特运用体会上。

以上就是今日沟通的全部内容,谢谢咱们的聆听,期望能对你有所帮助。

欢迎你经过 Twitter、 Discord 频道 或博客的留言板与我进行沟通。

订阅下方的 邮件列表,能够及时获得每周最新文章。

原文发表在我的博客wwww.fatbobman.com

欢迎订阅我的大众号:【肘子的Swift记事本】