SwiftWebUI:带你深入理解SwiftUI原理

470次阅读  |  发布于3年以前

6 月初,苹果在 WWDC 2019 大会上发布了 SwiftUI。这是一个单一的“跨平台”、“声明性”框架,可以用来构建 tvOS、macOS、watchOS 和 iOS/iPad OS 平台的 UI。而本文要介绍的 SwiftWebUI 则能将 SwiftUI 带到 Web 平台上。

SwiftWebUI GitHub 地址: https://github.com/SwiftWebUI/SwiftWebUI

免责声明: 记住这是一个娱乐项目!可别把它用到生产环境里哦。它可以帮助你深入了解 SwiftUI 及其内部的工作机制。

SwiftWebUI那么 SwiftWebUI 到底是什么?简单来说,你可以用它编写能显示在浏览器中的 SwiftUI 视图。详情请查看: https://developer.apple.com/documentation/swiftui/view

import SwiftWebUI

struct MainPage: View {
  @State var counter = 0

  func countUp() { counter += 1 }

  var body: some View {
    VStack {
      Text(" #\(counter)")
        .padding(.all)
        .background(.green, cornerRadius: 12)
        .foregroundColor(.white)
        .tapAction(self.countUp)
    }
  }
}

结果如下:

SwiftWebUI 和其他的一些方案不太一样,它不仅会将 SwiftUI 视图呈现为 HTML,还会在浏览器和 Swift 服务器中托管的代码之间建立一个连接,由此可以实现交互操作——按钮、选择器、步进器、列表、导航,所有这些都能用!

换句话说:SwiftWebUI 是 SwiftUI API 在浏览器上的(接近完整)的实现。

再强调一遍免责声明:记住这是一个娱乐项目!可别把它用到生产环境里哦。它可以帮助你深入了解 SwiftUI 及其内部的工作机制。

一次学习,随处使用

SwiftUI 的宗旨不是“一次编写,随处运行”,而是“一次学习,随处使用”。可别指望随手抓起一个 iOS 平台上写得好好的 SwiftUI 应用,然后把代码扔进 SwiftWebUI 项目,就能原样在浏览器上完美呈现出来。这不是它们的设计目标。

这里的关键是在各个平台复用同一套代码逻辑和框架。本文所关注的就是复用到 Web 平台上。

接下来我们开始深入研究,并编写一个简单的 SwiftWebUI 应用。本着“一次学习,随处使用”的精神,首先要看一遍这两个 WWDC 讲座:SwiftUI 简介和 SwiftUI 要点。还有一个讲座内容比较深,超出了本文涉及的范畴(并且讲座中涉及的概念大都被 SwiftWebUI 支持了):经过 SwiftUI 的数据流,详情请查看: https://developer.apple.com/videos/play/wwdc2019/226

相关链接:

SwiftUI 简介: https://developer.apple.com/videos/play/wwdc2019/204/

SwiftUI 要点: https://developer.apple.com/videos/play/wwdc2019/216

系统需求

macOS Catalina

目前,SwiftWebUI 需要运行在 macOS Catalina 系统上。还好大家可以很容易地在单独的 APFS 卷上安装 Catalina。另外 SwiftUI 需要调用大量 Swift 5.1 中的新功能,所以还得安装 Xcode 11 才行。

为什么需要 Catalina?SwiftUI 使用新的 Swift 5.1 运行时功能(例如不透明的结果类型)。这些功能在 Mojave 附带的 Swift 5 运行时中不可用(另一个原因是仅在 Catalina 中可用 Combine,尽管可以使用 OpenCombine 修复该部分)

tuxOSS

wiftWebUI 现在可以在 Linux 上使用 OpenCombine 运行(也可以在没有它的情况下运行,但有些东西不起作用,例如 NavigationView)。

需要 Swift 5.1 快照。我们还提供了一个包含 5.1 快照的 Docker 镜像:helje5 / swift,详情请查看: https://cloud.docker.com/repository/docker/helje5/swift/tags

Mojave 系统

想在 Mojave 与 Xcode 11 的组合上运行也能做到。你需要创建一个 iOS 13 模拟器项目,在里面运行所有内容。

第一个应用

创建 SwiftWebUI 项目

启动 Xcode 11,选择“File> New> Project ...”或按 Cmd-Shift-N:

选择“macOS / Command Line Tool”项目模板:

取个好名字,这里用“AvocadoToast”:

然后我们将 SwiftWebUI 添加为 Swift Package Manager 依赖项。该选项隐藏在“File / Swift Packages”菜单组中:

输入下面的链接作为包的URL: https://github.com/SwiftWebUI/SwiftWebUI.git

在“Branch”中选 master,始终获取最新更新(你也可以使用 revision 或 develop 分支):

最后将 SwiftWebUI 库添加到你的 tool target:

搞定了。现在你就有了一个可以 import SwiftWebUI 的工具项目。(Xcode 可能需要花些时间来获取和构建依赖项。)

SwiftWebUI Hello World

开始使用 SwiftWebUI 吧。打开 main.swift 文件并将其内容替换为:

import SwiftWebUI

SwiftWebUI.serve(Text("Holy Cow!"))

在 Xcode 中编译并运行应用,打开 Safari 并访问:http://localhost:1337/

背后发生了什么事情呢?首先是导入 SwiftWebUI 模块(可别搞错了,导入 macOS SwiftUI 就不对了)。

然后我们调用 SwiftWebUI.serve,它要么接受一个闭包返回一个视图,要么直接出一个视图,这里就是一个 Text视图(又名“UILabel”,可以显示普通或格式化的文本): https://developer.apple.com/documentation/swiftui/text

幕后

在程序内部,serve 函数创建了一个非常简单的 SwiftNIO HTTP 服务器,侦听端口 1337。当浏览器访问该服务器时,它会创建一个会话并将我们的(Text)视图传递给该会话。

最后 SwiftWebUI 在服务器上从视图中创建一个“Shadow DOM”,将其呈现为 HTML 并将结果发送到服务器。这个“Shadow DOM”(和搭配的状态对象)存储在这个会话中。

这里就是 SwiftWebUI 应用与 watchOS 或 iOS SwiftUI 应用之间的区别所在。一个 SwiftWebUI 应用不仅服务一个用户,而是为一组用户提供服务。

添加一些交互

接下来我们给代码做些改进。在项目中创建一个新的 Swift 文件并调用 MainPage.swift。然后为其添加一个简单的 SwiftUI 视图定义:

import SwiftWebUI

struct MainPage: View {

  var body: some View {
    Text("Holy Cow!")
  }
}

根据我们的自定义视图来调整一下 main.swift:

SwiftWebUI.serve(MainPage())

然后就不用管 main.swift 了,所有工作都能在自定义的视图中完成。下面添加一些交互:

struct MainPage: View {
  @State var counter = 3

  func countUp() { counter += 1 }

  var body: some View {
    Text("Count is: \(counter)")
      .tapAction(self.countUp)
  }
}

我们的视图得到了一个名为 counter 的持久状态变量(具体介绍见前面第一个 wwdc 讲座)。还有一个小函数来触发计数器。

然后我们使用 SwiftUI tapAction修饰符将事件处理程序附加到 Text 上。最后我们在标签中显示当前值:

幕后

这里程序又是怎么工作的呢?当浏览器点击我们的端点时,SwiftWebUI 在其中创建了会话和我们的“Shadow DOM”。然后它将描述我们视图的 HTML 发送到浏览器。然后 tapAction 向 HTML 添加了一个 onclick 处理程序。SwiftWebUI 还向浏览器发送少量的JavaScript,负责处理点击操作并将其转发到我们的 Swift 服务器。

然后轮到 SwiftUI 上场了。SwiftWebUI 将 click 事件与我们的“Shadow DOM”中的事件处理程序相关联并调用 countUp 函数。该函数修改了计数器状态变量,使视图的呈现无效。接着 SwiftWebUI 对“Shadow DOM”中的更改执行 diff 命令。然后这些更改被发送回浏览器。

这些“更改”被作为 JSON 数组发送出去,我们页面中的小 JavaScript 程序可以处理这些数组。如果整个子树发生了变化(例如,如果用户跳转到一个全新的视图),则更改可以是更大的 HTML 片段,应用于 innerHTML 或 outerHTML。

但通常情况下这些更改都不大,诸如 add class、set HTML attribute 等(比如浏览器 DOM 调整之类)。

前端人的成长离不开与优秀的同伴共同交流,你可以加入我们的“前端技术交流群”,社群内会经常讨论前端相关的技术、分享免费学习资料,我们也会邀请前端专家进行社群分享、直播、公开课等活动,winter 就在群里哦!如果你感兴趣,欢迎添加社群管理员微信 GeekUni004,回复“前端群”申请入群。

牛油果土司面包

Avocado Toast基础打得很牢固。下面该引入更多的交互了。下面的内容是基于“SwiftUI 要点”讲座中演示 SwiftUI 用的“Avocado Toast 应用”。这个应用是关于美味的吐司面包的。

我们写的 HTML/CSS 样式不是很完美也不够漂亮。你也知道我们不是网页设计师,需要大家帮助。欢迎提交贡献!

完整应用下载链接: https://github.com/SwiftWebUI/AvocadoToast

牛油果土司面包订单

讲座中的相关内容大约从 6 分钟开始,我们把其中的代码添加到新的 OrderForm.swift 文件中:

struct Order {
  var includeSalt            = false
  var includeRedPepperFlakes = false
  var quantity               = 0
}
struct OrderForm: View {
  @State private var order = Order()

  func submitOrder() {}

  var body: some View {
    VStack {
      Text("Avocado Toast").font(.title)

      Toggle(isOn: $order.includeSalt) {
        Text("Include Salt")
      }
      Toggle(isOn: $order.includeRedPepperFlakes) {
        Text("Include Red Pepper Flakes")
      }
      Stepper(value: $order.quantity, in: 1...10) {
        Text("Quantity: \(order.quantity)")
      }

      Button(action: submitOrder) {
        Text("Order")
      }
    }
  }
}

测试一下,在 main.swift 中将 SwiftWebUI.serve() 指向新的 OrderForm 视图。

浏览器中是这个样子:

SemanticUI(https://semantic-ui.com/)用来在 SwiftWebUI 中设置一些样式。这一步并不是非用它不可,它只是用来做一些好看的小部件的。

注意:这里只使用 CSS/fonts,不用 JavaScript 组件。

插点内容:SwiftUI

布局在 SwiftUI 要点讲座中大约 16 分钟的时候,他们会讲 SwiftUI 布局和视图修饰符排序:

var body: some View {
  HStack {
    Text("")
      .background(.green, cornerRadius: 12)
      .padding(.all)

    Text(" => ")

    Text("")
      .padding(.all)
      .background(.green, cornerRadius: 12)
  }
}

结果如下,请注意修饰符的排序:

SwiftWebUI 试图复制常见的 SwiftUI 布局,但还没有完全成功。毕竟它必须考虑浏览器提供的布局系统。欢迎 flexbox 专家提供帮助!

牛油果土司面包订单历史

再回来看应用。讲座 19 分 50 秒开始介绍列表视图,用于显示牛油果土司面包的订单历史记录。它在 Web 端长成这个样子:

List 视图遍历已完成订单的数组,为每个订单创建一个子视图(OrderCell),并传入列表中的当前项。

以下是我们使用的代码:

struct OrderHistory: View {
  let previousOrders : [ CompletedOrder ]

  var body: some View {
    List(previousOrders) { order in
      OrderCell(order: order)
    }
  }
}

struct OrderCell: View {
  let order : CompletedOrder

  var body: some View {
    HStack {
      VStack(alignment: .leading) {
        Text(order.summary)
        Text(order.purchaseDate)
          .font(.subheadline)
          .foregroundColor(.secondary)
      }
      Spacer()
      if order.includeSalt {
        SaltIcon()
      }
      else {}
      if order.includeRedPepperFlakes {
        RedPepperFlakesIcon()
      }
      else {}
    }
  }
}

struct SaltIcon: View {
  let body = Text("")
}
struct RedPepperFlakesIcon: View {
  let body = Text("")
}

// Model

struct CompletedOrder: Identifiable {
  var id           : Int
  var summary      : String
  var purchaseDate : String
  var includeSalt            = false
  var includeRedPepperFlakes = false
}

SwiftWebUI 列表视图效率非常低,它总是呈现整个子集。没有单元复用,啥都没有。在 Web 应用中有多种方法可以处理这种情况,例如使用分页或更多客户端逻辑。

讲座中用到的示例数据如下:

let previousOrders : [ CompletedOrder ] = [
  .init(id:  1, summary: "Rye with Almond Butter",  purchaseDate: "2019-05-30"),
  .init(id:  2, summary: "Multi-Grain with Hummus", purchaseDate: "2019-06-02",
        includeRedPepperFlakes: true),
  .init(id:  3, summary: "Sourdough with Chutney",  purchaseDate: "2019-06-08",
        includeSalt: true, includeRedPepperFlakes: true),
  .init(id:  4, summary: "Rye with Peanut Butter",  purchaseDate: "2019-06-09"),
  .init(id:  5, summary: "Wheat with Tapenade",     purchaseDate: "2019-06-12"),
  .init(id:  6, summary: "Sourdough with Vegemite", purchaseDate: "2019-06-14",
        includeSalt: true),
  .init(id:  7, summary: "Wheat with Féroce",       purchaseDate: "2019-06-31"),
  .init(id:  8, summary: "Rhy with Honey",          purchaseDate: "2019-07-03"),
  .init(id:  9, summary: "Multigrain Toast",        purchaseDate: "2019-07-04",
        includeSalt: true),
  .init(id: 10, summary: "Sourdough with Chutney",  purchaseDate: "2019-07-06")
]

牛油果吐司面包选择器讲座第 43 分钟开始讲解 Picker 控件及它与枚举一起使用的方法。首先是各种吐司选项的枚举:

enum AvocadoStyle {
  case sliced, mashed
}

enum BreadType: CaseIterable, Hashable, Identifiable {
  case wheat, white, rhy

  var name: String { return "\(self)".capitalized }
}

enum Spread: CaseIterable, Hashable, Identifiable {
  case none, almondButter, peanutButter, honey
  case almou, tapenade, hummus, mayonnaise
  case kyopolou, adjvar, pindjur
  case vegemite, chutney, cannedCheese, feroce
  case kartoffelkase, tartarSauce

  var name: String {
    return "\(self)".map { $0.isUppercase ? " \($0)" : "\($0)" }
           .joined().capitalized
  }
}

可以把它们加入 Order 结构中:

struct Order {
  var includeSalt            = false
  var includeRedPepperFlakes = false
  var quantity               = 0
  var avocadoStyle           = AvocadoStyle.sliced
  var spread                 = Spread.none
  var breadType              = BreadType.wheat
}

然后使用不同的 Picker 类型显示它们。循环枚举值的代码非常简洁:

Form {
  Section(header: Text("Avocado Toast").font(.title)) {
    Picker(selection: $order.breadType, label: Text("Bread")) {
      ForEach(BreadType.allCases) { breadType in
        Text(breadType.name).tag(breadType)
      }
    }
    .pickerStyle(.radioGroup)

    Picker(selection: $order.avocadoStyle, label: Text("Avocado")) {
      Text("Sliced").tag(AvocadoStyle.sliced)
      Text("Mashed").tag(AvocadoStyle.mashed)
    }
    .pickerStyle(.radioGroup)

    Picker(selection: $order.spread, label: Text("Spread")) {
      ForEach(Spread.allCases) { spread in
        Text(spread.name).tag(spread) // there is no .name?!
      }
    }
  }
}

结果:

这里也需要改进一下 CSS……

牛油果土司面包“最终版”应用

其实我们的结果和原版略有不同,也不是什么完整的版本。它看起来没那么完美,但毕竟这只是一个演示嘛

HTML 和 SemanticUI

SwiftWebUI 中的对应 UIViewRepresentable的等效组件负责发出原始 HTML。

这里提供了两种变体,HTML 按原样输出字符串,或者通过 HTML 转义内容:

struct MyHTMLView: View {
  var body: some View {
    VStack {
      HTML("<blink>Blinken Lights</blink>")
      HTML("42 > 1337", escape: true)
    }
  }
}

一般来说,你可以使用此原语构建任何 HTML。

HTMLContainer 的级别更高一些。例如,这是我们 Stepper 控件的实现:

var body: some View {
  HStack {
    HTMLContainer(classes: [ "ui", "icon", "buttons", "small" ]) {
      Button(self.decrement) {
        HTMLContainer("i", classes: [ "minus", "icon" ], body: {EmptyView()})
      }
      Button(self.increment) {
        HTMLContainer("i", classes: [ "plus", "icon" ], body: {EmptyView()})
      }
    }
    label
  }
}

HTMLContainer 是“反应性的”,即如果类、样式或属性发生变化(而不是重新呈现所有内容),它将发出常规 DOM 更改。

SemanticUI

SwiftWebUI 还有一些预设的 SemanticUI 控件:

VStack {
  SUILabel(Image(systemName: "mail")) { Text("42") }
  HStack {
    SUILabel(Image(...)) { Text("Joe") } ...
  }
  HStack {
    SUILabel(Image(...)) { Text("Joe") } ...
  }
  HStack {
    SUILabel(Image(...), Color("blue"),
             detail: Text("Friend"))
    {
      Text("Veronika")
    } ...
  }
}

呈现为:

请注意,SwiftWebUI 还支持一些 SFSymbols 图像名称(通过 Image(systemName:))。这些都是基于 SemanticUI 对 Font Awesome 的支持的: https://semantic-ui.com/elements/icon.html

还有 SUISegment、SUIFlag 和 SUICARD:

SUICards {
  SUICard(Image.unsplash(size: UXSize(width: 200, height: 200),
                         "Zebra", "Animal"),
          Text("Some Zebra"),
          meta: Text("Roaming the world since 1976"))
  {
    Text("A striped animal.")
  }
  SUICard(Image.unsplash(size: UXSize(width: 200, height: 200),
                         "Cow", "Animal"),
          Text("Some Cow"),
          meta: Text("Milk it"))
  {
    Text("Holy cow!.")
  }
}

呈现为:

添加此类视图非常简单,非常有趣。可以使用 SwiftUI 视图快速构建相当复杂和美观的布局。

Image.unsplash 使用 Unsplash API构建图像查询。只需输入一些参数,诸如图像尺寸和可选范围即可。

注意:有时 Unsplash 服务不怎么好用。

总结

上面就是我们的演示了,希望你能喜欢!但要再次重复 免责声明:记住这是一个娱乐项目!可别把它用到生产环境里哦。它可以帮助你深入了解 SwiftUI 及其内部的工作机制。

我们认为它是一个很好的玩具,可能也是一个有价值的工具。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8