千呼万唤始出来☑️:SwiftWebUI

作者:The Always Right Institute, 原文链接 ,原文日期:2019-06-30

译者: Ji4n1ng ;校对: numbbbbbWAMaker ;定稿: Pancf

六月初,Apple 在 WWDC 2019 上发布了 SwiftUI 。SwiftUI 是一个“跨平台的”、“声明式”框架,用于构建 tvOS、macOS、watchOS 和 iOS 上的用户界面。 SwiftWebUI 则将它带到了 Web 平台上:heavy_check_mark:。

免责声明:这是一个玩具项目!不要用于生产。使用 SwiftWebUI 是为了了解更多关于 SwiftUI 本身及其内部工作原理的信息。

SwiftWebUI

那么究竟什么是 SwiftWebUI ?它允许你编写可以在 Web 浏览器中显示的 SwiftUI 的 视图

import SwiftWebUI

struct MainPage: View {
  @State var counter = 0
  
  func countUp() { counter += 1 }
  
  var body: some View {
    VStack {
      Text("  :bread: #\(counter)")
        .padding(.all)
        .background(.green, cornerRadius: 12)
        .foregroundColor(.white)
        .tapAction(self.countUp)
    }
  }
}

结果是:

与其他一些工作不同,SwiftWebUI 不仅仅是将 SwiftUI 视图渲染为 HTML,而且还在浏览器和 Swift 服务器中托管的代码之间建立了一个连接,这样就可以实现各种交互功能——按钮、选择器、步进器、列表、导航等,这些都可以做到!

换句话说: SwiftWebUI 是针对浏览器的 SwiftUI API(很多部分但不是所有)的一种实现。

再次进行 免责声明 :这是一个玩具项目!不要用于生产。使用 SwiftWebUI 是为了了解更多关于 SwiftUI 本身及其内部工作原理的信息。

学习一次,随处使用

SwiftUI 的既定目标不是“ 编写一次,随处运行 ”,而是“ 学习一次,随处使用 ”。不要期望在 iOS 上开发了一个漂亮的 SwiftUI 应用程序,然后将它的代码放入 SwiftWebUI 项目中,并让它在浏览器中呈现完全相同的内容。这不是我们的重点。

关键是能够重用 SwiftUI 的原理并使其在不同平台之间共享。在这种情况下,SwiftWebUI 就达到目的了:heavy_check_mark:。

但是先让我们深入了解一下细节,并编写一个简单的 SwiftWebUI 应用程序。本着“学习一次,随处使用”的精神,首先观看这两个 WWDC 演讲: 介绍 SwiftUISwiftUI 要点 。本文不会过多的深入数据流有关的内容,但这篇演讲同样推荐观看(这些概念在 SwiftWebUI 中被广泛支持): SwiftUI 中的数据流

要求

到目前为止,SwiftWebUI 需要安装 macOS Catalina 来运行(“Swift ABI” ‍♀️)。幸运的是,将 Catalina 安装在单独的 APFS 卷 上非常容易。并且需要安装 Xcode 11 才能获得在 SwiftUI 中大量使用的 Swift 5.1 新功能。明白了吗?很好!

Linux 呢?这个项目确实准备在 Linux 上运行,但尚未完成。唯一还没完成的事情是对 Combine PassthroughSubject 的简单实现以及围绕它的一些基础设施。准备: NoCombine 。欢迎来提 PR!

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”菜单中:

输入 https://github.com/SwiftWebUI/SwiftWebUI.git 作为包的 URL:

使用“Branch” master 选项,以便于总能获得最新和最好的版本(也可以使用修订版或 develop 分支):

最后,将 SwiftWebUI 库添加到你的工具的 target 中:

这就完成了创建。你现在有了一个可以导入 SwiftWebUI 的工具项目。(Xcode 可能需要一些时间来获取和构建依赖。)

SwiftWebUI Hello World

让我们开始使用 SwiftWebUI。打开 main.swift 文件,将其内容替换为:

import SwiftWebUI

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

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

这里发生了什么:首先导入 SwiftWebUI 模块(不要意外导入 macOS SwiftUI :grinning:)。

然后我们调用了 SwiftWebUI.serve ,它要么接受一个返回视图的闭包,要么就直接是一个视图——如下所示:一个 Text 视图(也称为“UILabel”,它可以显示纯文本或格式化的文本)。

幕后发生的事情

在内部, serve 函数创建一个非常简单的 SwiftNIO HTTP 服务器,它将会监听 1337 端口。当浏览器访问该服务器时,它会创建一个 session (会话)并将(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 作用于我们的定制视图:

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 的持久 状态 变量(不知道这是什么?再看一下 SwiftUI 的介绍)。还有一个可以使计数器加一的小函数。

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

魔法

幕后发生的事情

这是如何运作的?当浏览器访问端点时,SwiftWebUI 在其中创建了会话和“Shadow DOM”。然后将描述视图的 HTML 发送到浏览器。 tapAction 通过向 HTML 添加 onclick 处理程序来工作。SwiftWebUI 还向浏览器发送 JavaScript(少量,没有大的 JavaScript 框架!),处理点击并将其转发到 Swift 服务器。

然后 SwiftUI 的魔法开始生效。SwiftWebUI 将 click 事件与“Shadow DOM”中的事件处理程序相关联,并调用 countUp 函数。该函数通过修改 counter 状态 变量,使视图的渲染无效。SwiftWebUI 开始工作,并对“Shadow DOM”中的变更进行差异比较。然后将这些变更发送回浏览器。

“变更”作为 JSON 数组发送,页面中的小型 JavaScript 可以处理这些数组。如果整个子树发生了变化(例如,如果用户导航到一个全新的视图),则变更可以是应用于 innerHTMLouterHTML 的更大的 HTML 片段。

但通常情况下,这些变更都很小,例如 添加类设置 HTML 属性 等(即浏览器 DOM 修改)。

:bread: Avocado Toast

太好了,基础的部分可以正常工作了。让我们引入更多的交互。以下是基于 SwiftUI 要点 演讲中演示 SwiftUI 的“Avocado Toast App”。没看过吗?你应该看看,讲的是美味的吐司。

HTML / CSS 样式不漂亮也不完美。你知道,我们不是网页设计师,而且需要帮助。欢迎来提交 PR!

想要跳过细节,观看应用程序的 GIF 并在 GitHub 上下载: :bread:

:bread:订单

谈话从这(~6:00)开始,可以将这些代码添加到新的 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 用于在 SwiftWebUI 中设置一些样式。SemanticUI 并不是必须的,这里只是用它的控件来美化界面。

注意:仅使用 CSS 和字体,而不是 JavaScript 组件。

幕间休息:一些 SwiftUI 布局

SwiftUI 要点 演讲的 16:00 左右,他们将介绍 SwiftUI 布局和视图修改器排序:

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

结果如下,请注意修饰符的排序是如何相关的:

SwiftWebUI 尝试复制常见的 SwiftUI 布局,但还没有完全成功。毕竟它必须处理浏览器提供的布局系统。需要帮助,欢迎弹性盒布局相关的专家!

:bread:订单历史

回到应用程序,演讲(~19:50)介绍了 列表 视图,用于显示 Avocado toast 订单历史记录。这就是它在 Web 上的外观:

列表 视图遍历已完成订单的数组,并为每个订单创建一个子视图( 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 列表视图效率很低,它总是呈现整个子集合。没有单元格重用,什么都没有:sunglasses:。在一个网络应用程序中有各种各样的方法来处理这个问题,例如使用分页或更多客户端逻辑。

你不必手动输入演讲中的样本数据,我们为你提供了这些数据:

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")
]

:bread:涂抹酱选择器

选择器控件以及如何将它与枚举一起使用将在(~43:00)进行演示。首先是各种吐司选项的枚举:

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
}

然后使用不同的选择器类型来显示它们。如何循环枚举值非常简单:

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 的热爱来让它看起来更好看…

完成后的 :bread:应用

不,我们与原版略有不同,也没有真正完成应用。它看起来并不那么棒,但毕竟只是一个演示示例:sunglasses:。

完成后的应用程序可在GitHub: AvocadoToast 上获取。

HTML 和 SemanticUI

UIViewRepresentable 在 SwiftWebUI 中对应的实现,是直接使用原始 HTML。

它提供了两种变体,一种是 HTML 按原样输出字符串,另一种是通过 HTML 转义内容:

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

使用这个原语,基本上可以构建所需的任何 HTML。

还有一种更高级的用法是 HTMLContainer,SwiftWebUI 内部也用到了它。例如,这是步进器控件的实现:

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 是“响应式的”,即如果类、样式或属性发生变化,它将触发(emit)常规 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 的支持

还有 SUISegmentSUIFlagSUICARD

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!.")
  }
}

……渲染为这些内容:

添加此类视图非常简单,也非常有趣。可以使用


WOComponent

的 SwiftUI 视图来快速构建相当复杂和美观的布局。

Image.unsplash 根据 http://source.unsplash.com 上运行的 Unsplash API 来构建图像的查询。只需给它一些查询词、大小和可选范围。

注意:有时,特定的 Unsplash 服务似乎有点慢且不可靠。

总结

这就是我们的演示示例。我们希望你能喜欢!但要再次进行 免责声明 :这是一个玩具项目!不要用于生产。使用 SwiftWebUI 是为了了解更多关于 SwiftUI 本身及其内部工作原理的信息。

我们认为它是一个很好的玩具,可能也是一个有价值的工具,以便于更多地了解 SwiftUI 的内部工作原理。

技术随记

这些只是关于该技术的各个方面的一些笔记。可以跳过,这个不是那么的有趣:sunglasses:。

问题

SwiftWebUI 有很多问题,有些是在 GitHub 上提出的: Issues 。欢迎来提更多问题。

相当多的 HTML 布局的东西有问题(例如 ScrollView 并不总是滚动的),还有一些像 Shapes 这样的正在讨论方案的功能也有问题(可能通过 SVG 和 CSS 很容易做到)。

哦,还有一个例子是 If-ViewBuilder 不能正常工作。不明白为什么:

var body: some View {
  VStack {
    if a > b {
      SomeView()
    }
    // currently need an empty else: `else {}` to make it compile.
  }
}

需要帮忙!欢迎来提交 PR!

与原来的 SwiftUI 相比

本文的实现非常简单且效率低下。在现实情况下,必须以更高的速率来处理状态修改事件,以 60Hz 的帧速率做所有的动画等等。

我们侧重于使基本操作正确,例如状态和绑定如何工作,视图如何以及何时更新等等。很可能本文的实现在某些方面并不正确,可能是因为 Apple 忘了将原始资源作为 Xcode 11 的一部分发送给我们。

WebSockets

我们目前使用 AJAX 将浏览器连接到服务器。使用 WebSockets 有多种优势:

  • 保证了事件的顺序(AJAX 请求可能不同步到达)
  • 非用户发起的服务器端 DOM 更新(定时器、推送)
  • 会话超时指示器

这会让实现一个聊天客户端的演示示例变得非常容易。

添加 WebSockets 实际上非常简单,因为事件已经作为 JSON 发送了。我们只需要客户端和服务器端的垫片(shims)。所有这些都已经在 swift-nio-irc-webclient 中试用过了,只需要移植一下。

SPA

SwiftWebUI 的当前版本是一个连接到有状态后端服务器的 SPA(单页面应用程序)。

还有其他方法可以做到这一点,例如,当用户通过正常的链接遍历应用程序时,保持树的状态。又名 WebObjects。;-)

一般来说,最好能更好地控制 DOM ID 生成、链接生成以及路由等等。这和 SwiftObjects 所提供的方式类似。

但是最终用户将不得不放弃很多本可以“学习一次,随处使用”的功能,因为 SwiftUI 操作处理程序通常是围绕着捕捉任意状态的事实来构建的。

我们将会期待基于 Swift 的服务器端框架提出什么更好的东西来:alien:。

WASM

一旦我们找到合适的 Swift WASM(WebAssembly),SwiftWebUI 就会更有用处。期待 WASM!

WebIDs

有些像 ForEach 这样的 SwiftUI 视图需要 Identifiable 对象,其中的 id 可以是任何 Hashable 。这在 DOM 中不太好,因为我们需要基于字符串的 ID 来识别节点。

这是通过将 ID 映射到全局映射中的字符串来解决的。这在技术上是无界的(一个类引用的特定问题)。

总结:对于 web 代码,最好使用字符串或整型来标识个体。

表单

表单需要做得更好: Issue

SemanticUI 有一些很好的表单布局,我们可能参照这些布局重写子树。有待商榷。

面向 Swift 的 WebObjects 6

花了点时间在文章中嵌入了下面这个可点击的 Twitter 控件。(译者注:由于某些原因,这里没办法像原文一样嵌入 Twitter 控件,只能放链接。)

https://twitter.com/helje5/status/1137092138104233987/photo/1?ref_src=twsrc%5Etfw%7Ctwcamp%5Etweetembed%7Ctwterm%5E1137092138104233987&ref_url=http%3A%2F%2Fwww.alwaysrightinstitute.com%2Fswiftwebui%2F

苹果确实给了我们一个“Swift 风格”的 WebObjects 6!

下一篇:直面 Web 和一些 Swift 化的 EOF(又名 CoreData 又名 ZeeQL)。

链接

联系方式

嘿,我们希望你能喜欢这篇文章,并且也希望得到你的反馈!

Twitter(任何一个都可以): @helje5@ar_institute

电子邮件: wrong@alwaysrightinstitute.com

Slack:在 SwiftDE、swift-server、noze、ios-developers 上找到我们。

写于 2019 年 6 月 30 日

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问http://swift.gg。