Tokamak
🛠 ⚛️ 📲
纯Swift编写的类似React的原生UI框架
Tokamak提供了一种声明性、可测试和可缩放的API,用于构建由完全原生存视图支持的UI组件。您可以使用它来创建新的iOS应用,或者在极小的工作量和无需重写代码或改变应用整体架构的情况下将其添加到现有应用中。
Tokamak重新创建了React Hooks API,通过Swift的强类型系统、高性能和高效的内存管理进行了改进,因为它被编译为原生二进制文件。
与标准的UIKit MVC或其他建立在它之上的模式(MVVM、MVP、VIPER等)相比,Tokamak提供
-
原生UI的声明式领域特定语言(DSL):不再由Storyboard引起的冲突,没有模板语言或XML。使用Swift简洁地描述您应用程序的UI,并获得对iOS的全支持,包括可访问性、自动布局和原生导航手势。
-
易于使用的单向数据绑定:厌烦了
didSet
、委托、通知或KVO吗?UI组件会自动响应状态的变化而更新。 -
干净的可组合架构:组件可以作为子组件传递给其他组件,API专注于代码重用。您可以轻松地在现有的UIKit代码中嵌入Tokamak组件,反之亦然:将代码公开为Tokamak组件。无需决定是否应该从
UIView
或UIViewController
子类化来使您的UI可组合。 -
单元测试的离屏渲染:无需维护那些缓慢且易出错的UI测试,这些测试需要在一个模拟器屏幕上渲染一切,并模拟实际触摸事件以测试UI逻辑。使用Tokamak编写的组件可以离屏进行测试,测试完成只需几秒钟。如果你的UI不需要任何特定于
UIKit
的代码(Tokamak提供了帮助工具),你甚至可以在Linux上运行你的UI相关单元测试! -
平台无关的核心:我们的主要目标是最终支持尽可能多的平台。从iOS/UIKit和基本的macOS/AppKit支持开始,我们计划在未来版本中添加对WebAssembly/DOM和原生动安的渲染器。由于核心API是跨平台的,使用Tokamak编写的UI组件不需要改变即可在新添加的平台上架,除非你需要针对特定设备或操作系统特定的UI逻辑。如果确实需要,你也可以通过简单的组合干净地区分平台特定的组件。
-
经过验证的工作架构:React已经存在多年,获得了很大的关注,并且仍在增长。我们看到了很多使用它成功重建的应用程序,并且听到了对React本身的积极反馈,但我们也看到了很多关于其过度依赖JavaScript的抱怨。Tokamak让React的建立模式和架构在Swift中得以实现。
重要:就目前而言,Tokamak相对稳定,即没有维护者所知的任何阻止性或关键性错误。Component
和Hooks
类型的核心API已经冻结,并且有大量标准组件,可以开始基于iOS构建有用的应用程序。macOS/AppKit渲染器只支持最基本的功能,提高其与iOS渲染器的功能相似性是顶级优先项。如果将来确实需要任何破坏性的更改,我们旨在以源兼容的方式弃用旧API,并将逐步引入任何替代品。需要注意的是,源破坏性更改不是总能避免的,但它们将随着适当的版本号更改和迁移指南进行反映。
别忘了查看Tokamak在Spectrum上的社区,并留下你的反馈、评论和问题!
目录
示例代码
一个Tokamak组件的示例,该组件将按钮绑定到标签,并嵌入到一个现有的UIKit应用程序中,看起来像这样
import Tokamak
struct Counter: LeafComponent {
struct Props: Equatable {
let countFrom: Int
}
static func render(props: Props, hooks: Hooks) -> AnyNode {
let count = hooks.state(props.countFrom)
return StackView.node(.init(
Edges.equal(to: .parent),
axis: .vertical,
distribution: .fillEqually), [
Button.node(Button.Props(
onPress: Handler { count.set { $0 + 1 } },
text: "Increment"
)),
Label.node(.init(alignment: .center, text: "\(count.value)"))
])
}
}
然后你可以这样将这个组件添加到任何iOS应用中作为视图控制器
import TokamakUIKit
final class ViewController: TokamakViewController {
override var node: AnyNode {
return Counter.node(.init(countFrom: 1))
}
}
或者将其添加到 macOS 应用程序中
import TokamakAppKit
final class ViewController: TokamakViewController {
override var node: AnyNode {
return View.node(
.init(Style([
Edges.equal(to: .parent),
Width.equal(to: 200),
Height.equal(to: 100),
])),
Counter.node(.init(countFrom: 1))
)
}
}
请注意,我们添加了明确的约束来将其用作窗口的根视图控制器,并且窗口默认没有固定的预定义大小。
示例项目
尝试 Tokamak 的最佳方式是运行示例项目
- 请确认您已安装 CocoaPods 和 Xcode 10.1 或更高版本
pod --version
xcode-select -p
- 克隆存储库
git clone https://github.com/MaxDesiatov/Tokamak
- 在示例项目中安装依赖
cd Tokamak/Example
pod install
- 从文件浏览器或终端打开
Example
工作空间
open -a Xcode *.xcworkspace
- 构建可执行目标
TokamakDemo-iOS
(iOS)和TokamakDemo-macOS
(macOS)。
标准组件
Tokamak 提供了一些基本组件,您可以在您的应用程序中重新使用。在 iOS 上,这些组件会被渲染为您已经熟悉的相关 UIView
子类,例如,Button
组件被渲染为 UIButton
,Label
被渲染为 UILabel
等。查看 完整更新列表 获取更多信息。
快速介绍
我们尽量保持 Tokamak 的 API 尽可能简单,核心算法及其支持协议/结构目前只有 ~600 行代码。它基于几个基本概念构建。
Props
Props
描述了你希望在用户屏幕上看到的“配置”。一个例子可能是一个包含背景颜色、布局、初始值等内容的struct
。Props
是不可变的,且是Equatable
的,这使得我们能够观察它们何时改变。你总是使用struct
或enum
,而不能使用class
来表示props,以确保不可变。你不需要为你自己的Props
提供Equatable
实现,因为Swift编译器能够为你自动生成一个后台生成。以下是一个你可以用来构建你自己的组件(如上面示例中的Counter
)的简单Props
结构体:
struct Props: Equatable {
let countFrom: Int
}
子元素
有时候,“配置”是以树状结构描述的。例如,一个视图列表包含一个子视图数组,这些子视图本身还可以包含其他子视图。在Tokamak中,这被称为Children
,与Props
类似但足以单独对待。Children
也是不可变的并且是Equatable
的,这使得我们也能观察到它们的变化。
组件
Component
是一个协议,它将特定的Props
和Children
组合到屏幕上,并提供了如何在屏幕上渲染这些元素的声明。
protocol Component {
associatedtype Props: Equatable
associatedtype Children: Equatable
}
(如果你不知道associatedtype
是什么,不用担心,这只是对组件的要求提供一个类型并使它们成为Equatable
的简单要求。如果你知道什么是PAT,也不要担心。)
节点
节点是Props
、Children
和一个符合Component
类型(用于渲染“配置”)的容器。如果你熟悉React,Tokamak中的节点与React中的元素相对应。当Children
是一个节点数组时,我们可以间接形成描述应用程序UI的树。相应地,节点是不可变的并且是Equatable
的。你只需要使用Tokamak提供的标准AnyNode
类型即可。
struct AnyNode: Equatable {
// ... `Props` and `Children` stored here by Tokamak as private properties
}
这里是Tokamak提供的标准StackView
组件中用作Children
的节点数组的示例,它描述了堆叠视图的子视图。
struct StackView: Component {
struct Props: Equatable {
// ...
}
typealias Children = [AnyNode]
}
对于Tokamak提供的每个组件,都可以通过提供props和子节点方便地为其创建一个节点
// this extension and its `node` function are defined for you by Tokamak
extension Component {
static func node(_ props: Props, _ children: Children) -> AnyNode {
// ...
}
}
例如,创建一个空垂直堆叠视图的方式如下
StackView.node(.init(axis: .vertical), [])
渲染函数
最简单的组件是将Props
和Children
作为参数接收的纯函数,并返回一个节点树作为结果的简单组件
protocol PureComponent: Component {
// this is the function you define for your own components,
// Tokamak takes care of the rest
static func render(props: Props, children: Children) -> AnyNode
}
当从父组件传递给它们的Props
或Children
更改时,Tokamak会在组件上调用render
。您不必自己调用render
,只需将不同的值作为props或children传递给从父render
返回的节点,Tokamak将仅更新屏幕上需要更新的视图。
请注意,render
函数不返回其他组件,而是返回描述其他组件的节点。这是一个非常重要的区别,它使Tokamak保持高效,并避免更新组件的深度树。
以下是一个简单组件的示例,该组件将它的子节点以通过Props
传递的次数垂直堆叠渲染
struct StackRepeater: PureComponent {
typealias Props = UInt
typealias Children = AnyNode
static func render(props x: UInt, children: AnyNode) -> AnyNode {
return StackView.node(
.init(axis: .vertical),
(0..<x).map { _ in children }
)
}
}
然后您可以使用StackRepeater
在任何其他组件中使用,通过创建其节点并以此方式将任何其他节点作为子节点传递
StackRepeater.node(5, Label.node("repeated"))
这样就会在屏幕上显示一个文本为"repeated"
的标签,重复5次。
叶子组件
某些组件可能完全不需要Children
,对于这些组件,Tokamak提供了一个PureLeafComponent
辅助协议,允许您实现一个具有更简单签名的单个函数
// Helpers provided by Tokamak:
struct Null: Equatable {}
protocol PureLeafComponent: PureComponent where Children == Null {
static func render(props: Props) -> AnyNode
}
extension PureLeafComponent {
static func render(props: Props, children: Children) -> AnyNode {
return render(props: props)
}
}
因此,您的组件可以符合PureLeafComponent
而不是PureComponent
,这允许您在不需要时避免在render
函数中传递children
参数。
钩子
您通常需要具有状态或引起其他副作用的组件。钩子提供了一种清晰的方法将声明式组件与其他命令式代码(如状态管理、文件I/O、网络等)分离。
Tokamak中的标准协议CompositeComponent
将钩子注入到render
函数作为参数。
protocol CompositeComponent: Component {
static func render(
props: Props,
children: Children,
hooks: Hooks
) -> AnyNode
}
实际上,标准的 PureComponent
是一个特殊的 CompositeComponent
情况,在渲染过程中不使用 Hooks
。
// Helpers provided by Tokamak:
protocol PureComponent: CompositeComponent {
static func render(props: Props, children: Children) -> AnyNode
}
extension PureComponent {
static func render(
props: Props,
children: Children,
hooks: Hooks
) -> AnyNode {
return render(props: props, children: children)
}
}
最简单的 Hooks 之一是 state
。它允许组件拥有自己的状态,并在状态变化时更新。我们已经在 Counter
示例中看到了它的使用
struct Counter: LeafComponent {
// ...
static func render(props: Props, hooks: Hooks) -> AnyNode {
// type signature for this constant is inferred automatically
// and is only added here for documentation purposes
let count: State<Int> = hooks.state(1)
// ...
}
}
它返回一个非常简单的状态容器,在 render
的初始调用时包含值为 1
的内容,并在后续更新时通过传递给 count.set(_: Int)
的值来更新
// values of this type are returned by `hooks.state`
struct State<T> {
let value: T
// set the state to a value you already have
func set(_ value: T)
// or update the state with a pure function
func set(_ transformer: @escaping (T) -> T)
// or efficiently update the state in place with a mutating function
// (helps avoiding expensive memory allocations when state contains
// large arrays/dictionaries or other copy-on-write value)
func set(_ updater: @escaping (inout T) -> ())
}
请注意,set
函数不是 mutating
的,它们永远不会立即同步更新组件的状态,而是只会在 later time 上安排一个带有 Tokamak 的更新。只有使用 hooks.state
获取该状态的组件才会安排一个对 render
的调用。
当你需要更新任何子组件的状态时,你可以将状态值作为节点返回的 props 或子元素传递。在 Counter
组件中,标签的内容就是以这种方式“绑定”到 count
。
struct Counter: LeafComponent {
static func render(props: Null, hooks: Hooks) -> AnyNode {
let count = hooks.state(1)
return StackView.node([
Button.node(.init(
onPress: Handler { count.set { $0 + 1 } },
text: "Increment"
)),
Label.node("\(count.value)"),
])
}
}
Hooks 提供了一种非常好的方式来组合副作用,并将它们与你的组件代码分开。你总是可以通过重用现有钩子创建自己的钩子:只需将其添加到你的 extension Hooks
中即可,以最适合你的方式。
渲染器
当将 Tokamak 的架构映射到之前在 iOS 中建立的内容时,《Component
》对应于一个“视图模型”层,而《Hooks
》提供了一个可重用的“控制器”层。从这些角度来看,一个 Renderer
是一个“视图”层,但它完全由 Tokamak 管理。这不仅极大地简化了组件的代码,并允许您实现声明性,而且也完全解耦了特定平台的代码。
请注意,上面的 Counter
组件不包含来自 UIKit
模块的任何类型,尽管组件本身是通过其 TokamakViewController
公共 API 传递给特定的 UIKitRenderer
的,以便在该使用 UIKit
的应用程序中使用。在其他平台上,你可以使用不同的渲染器,而如果该环境的组件行为不需要变化,组件代码可以保持不变。否则,您可以通过 Props
调整组件的行为,并根据渲染器的平台传递不同的“初始化”props。
为其他平台提供渲染器是我们的首要任务之一。Tokamak 已经在 TokamakAppKit
模块中为 macOS 应用程序提供了基础支持,这允许您在 iOS 和 macOS 上渲染相同的标准组件,而无需对组件代码进行任何更改,也无需使用 Marzipan!
要求
- iOS 11.0 或更高版本用于
TokamakUIKit
- macOS 10.14 用于
TokamakAppKit
- Xcode 10.1 或更高版本
- Swift 4.2
安装
CocoaPods
CocoaPods 是 Swift 和 Objective-C Cocoa 项目的依赖管理器。您可以使用以下命令安装它
$ gem install cocoapods
导航到项目目录,使用以下命令创建 Podfile
$ pod install
在您的 Podfile
中指定 Tokamak
pod
# Uncomment the next line to define a global platform for your project
# platform :ios, '11.0'
target 'YourApp' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
# Pods for YourApp
pod 'TokamakUIKit', '~> 0.1'
end
然后,运行以下命令
$ pod install
打开创建的 YourApp.xcworkspace
文件。这应该是您每天创建应用程序时使用的文件,而不是 YourApp.xcodeproj
文件。
常见问题解答
“Hooks 规则”是什么?
Hooks 是将状态和其他副作用注入纯函数的绝佳方式。从某种意义上说,您可以将 Hooks 视为 indexed monads 或 代数效应 的模拟,这些模拟为 React 中的 Hooks 提供了灵感。遗憾的是,由于 Swift 当前的局限性,我们无法原生地表达 monads 或代数效应,因此 Hooks 需要应用一些限制才能使其正常工作。类似限制也应用于 React 中的 Hooks
- 您可以从任何组件的
render
函数中调用 Hooks。👍 - 您可以从您自己的 custom Hooks(在
Hooks
的extension
中定义)中调用 Hooks。🙌 - 不要在循环、条件或嵌套函数/闭包中调用 Hooks。
🚨 - 不要从任何不是组件上的
static func render
或不是 custom Hook 的函数中调用 Hooks。⚠️
在未来的版本中,Tokamak 将提供一种在编译时捕获 Hooks 规则违规的 linter。
存在规则 Hooks 的原因是什么?
与 React 相同,Tokamak 为每个具有状态的组件维护一个“内存单元”数组,以保存实际的状态。它需要区分一次 Hooks 调用与另一次调用来在执行组件的 `render
` 函数过程中将其映射到相应的单元格。考虑以下情况
struct ConditionalCounter: LeafComponent {
typealias Props = Null
static func render(props: Props, hooks: Hooks) -> AnyNode {
// this code won't work as expected as it violates Rule 3:
// > Don't call Hooks from a condition
// state stored in "memory cell" 1
let condition = hooks.state(false)
if condition {
// state stored in "memory cell" 2
count = hooks.state(0)
} else {
// state, which should be stored in "memory cell" 3,
// but will be actually queried from "memory cell" 2
count = hooks.state(42)
}
return StackView.node([
Switch.node(.init(value: condition.value,
valueHandler: Handler(condition.set)))
Button.node(.init(
onPress: Handler { count.set { $0 + 1 } },
text: "Increment"
)),
Label.node("\(count.value)"),
])
}
}
Tokamak 渲染器是如何知道在后续对 `ConditionalCounter.render
` 的调用中实际上正在进行哪种状态的?它依赖于这些调用的顺序,因此如果顺序从一个渲染到另一个渲染发生变化,则可能会意外地得到一个状态单元的值,而不是您期望的不同状态单元的值。
我们鼓励您将所有挂钩逻辑保持在一个 `render
` 定义的最顶层,这样就可以清晰地看到组件的所有副作用,而且这本身就是一种良好的做法。如果您确实需要条件或循环,您始终可以创建一个单独的组件,并在父组件的 `render
` 方法中根据条件返回节点或节点数组作为此新子组件。固定的 `ConditionalCounter
` 版本看起来像这样
struct ConditionalCounter: LeafComponent {
typealias Props = Null
static func render(props: Props, hooks: Hooks) -> AnyNode {
// this works as expected
let condition = hooks.state(false)
let count1 = hooks.state(0)
let count2 = hooks.state(42)
let value = (condition ? count1 : count2).value
return StackView.node([
Switch.node(.init(value: condition.value,
valueHandler: Handler(condition.set)))
Button.node(.init(
onPress: Handler { count.set { $0 + 1 } },
text: "Increment"
)),
Label.node("\(count.value)"),
])
}
}
Tokamak 为什么使用值类型和协议而不是类?
由于在 UIKit
和 AppKit
(尽管之前苹果强调了 组合优于继承的好处)中存在大量的类继承结构,Swift 开发者可能已经习惯了类。不幸的是,虽然 UIKit
是一种相对较新的开发,但它仍然紧密遵循许多在 AppKit
中使用的模式,而 AppKit
本身是在 20世纪80年代晚期 开发的。这两个都是针对 Objective-C 进行的开发,而 Swift 成为公共信息以及 面向协议的模式的建立都发生在 Swift 公布多年之前。
Tokamak 的一个主要目标是构建一个感觉像是原生的 Swift UI 框架。当与基于类的 API 相比时,Tokamak 的 API 带来了这些好处
- 不需要 将
NSObject
子类化以遵守常用协议; - 不需要使用
override
并记住调用super
; - 不需要
required init
、convenience init
或关注严格的类初始化规则; - 您无法使用不可变值创建引用周期,因此在使用回调时不需要
weakSelf/strongSelf
提示; - 您不必担心修改一个在不同作用域意外捕获的引用的对象:不可变值被隐式复制,并且大多数复制在编译器优化期间被删除;
- 关注组合优于继承:如果您只需要简单的定制,就不需要子类化
UIViewController
或UIView
以及担心上述所有内容。 - 关注函数性和声明性编程,同时仍然可以在需要时使用命令式代码:值类型保证纯函数中没有意外的副作用。
JSX的功能?
Tokamak是否有类似目前答案是没有,但我们发现Tokamak的API与React.createElement
语法相比,允许你更简洁地创建节点。实际上,使用Tokamak的.node
API时,你不需要编写JSX中必需的关闭元素标签。例如,比较这个
Label.node("label!")
到这个
<Label>label!</Label>
我们确实认为对于属性,需要.init
的开销,并且需要进行属性初始化器参数的有序排列。对于后者,Tokamak有一个有用的约定,即所有属性初始化器命名参数应按字母顺序排列。
主要问题是,目前没有易于扩展的Swift解析器或宏系统可用,无法实现类似于JSX的功能用于Tokamak。尽可能简化实现,我们肯定会考虑它作为一个选项。
Component
协议上render
函数是静态的?
为什么在以这种方式设计框架的API的另一种方法是我们可以将组件定义为普通函数,这些函数不需要是静态的。
func counter(hooks: Hooks) -> AnyNode {
// ...
}
问题在于,我们需要在组件上进行等价比较,以便能在AnyNode
上定义Equatable
。这对于普通函数是不可用的。
let x = counter
let y = counter
// won't compile
x == y
// won't compile: reference equality is also not defined on functions,
// even though functions are reference types ¯\_(ツ)_/¯
x === y
具有静态函数的协议和结构允许我们解决这一问题,并使用协议和Equatable
约束来形式化不同类型的组件层次。
// equality comparison is available for types
struct Counter {
static func render(hooks: Hooks) -> AnyNode {
// ...
}
}
// Tokamak does something like this internally for your components,
// consider following a pseudocode:
let xComponent = Counter.self
let yComponent = OtherComponent.self
var rendered: AnyNode?
if xComponent != yComponent {
rendered = xComponent.render()
}
我们可以从Component
协议中的render
移除static
,但这使可能索引非static
版本的render
的实例属性。这种方式可能使组件意外地变得具有状态性,隐藏了组件实际上是函数而不是实例的事实。考虑这个假想的API
struct Counter {
// this makes `Counter` component stateful,
// but prevents observing state changes
var count = 0
// no `static` here, which makes `var` above accessible
func render() -> AnyNode {
return Label.node("\(count)")
}
}
现在可以直接访问组件的状态,但是当我们更改这个状态时,我们无法轻松地调度组件树的更新。我们可能需要要求组件的作者在实例的每个属性上实现didSet
,这既繁琐又难以执行。而将render
标记为static
使得引入不可观察的局部状态变得更困难,而可观察的局部状态则由Hooks
来管理。
致谢
- 感谢Swift 社区建立了一个最优秀的编程语言之一!
- 感谢React 社区构建了一个既实用又优雅的 UI 框架,同时保持与 JavaScript 的兼容性。
😄 - 感谢Render、ReSwift、Katana UI和Komponents给我们带来的启发!
捐赠
本项目遵循捐赠者守则。参与本项目的你被认为应遵守此守则。如有不适当行为,请向[email protected]报告。
主持人
Max Desiatov,Matvii Hodovaniuk
许可协议
Tokamak 适用于 Apache 2.0 许可协议。有关更多信息,请参阅 LICENSE 文件。