Tokamak
🛠 ⚛️ 📲
用纯 Swift 编写的原生 UI 的 React-like 框架
Tokamak 为构建 UI 组件提供了声明性、可测试性和可扩展的 API,这些组件由完全原生的视图支持。您可以将其用于新的 iOS 应用程序,也可以轻松地将其添加到现有应用程序中,无需重写其他代码或更改应用程序的整体架构。
Tokamak 复制并改进了 React Hooks API,利用 Swift 的强类型系统、高性能和高效的内存管理,通过将其编译为本地二进制文件实现。
与标准 UIKit MVC 或其他建立在它之上的模式(如 MVVM、MVP、VIPER 等)相比,Tokamak 提供:
-
原生 UI 的声明性 DSL:不再因 Storyboards 而产生冲突,没有模板语言或 XML。使用 Swift 简洁地描述您的应用程序 UI,并获得具有全功能支持的原生 iOS 查看、自动布局和本地导航手势。
-
易于使用的一向数据绑定:厌倦了
didSet
、代理、通知或 KVO 吗?UI 组件会自动根据状态更改进行更新。 -
干净的组合架构:组件可以作为子组件传递给其他组件,API 主要关注代码重用。您可以轻松地将 Tokamak 组件嵌入到现有的 UIKit 代码中,反之亦然:将代码公开为 Tokamak 组件。无需决定是否需要派生
UIView
或UIViewController
来使您的用户界面可组合。 -
脱屏渲染以进行单元测试:无需维护在模拟器屏幕上渲染一切并模拟实际触摸事件来仅测试 UI 逻辑的缓慢且不可靠的 UI 测试。用 Tokamak 编写的组件可以在脱屏中测试,测试完成只需几秒钟。如果您的 UI 不需要任何特定的
UIKit
代码(而 Tokamak 提供了助手来实现这一点),您甚至可以在 Linux 上运行与 UI 相关的单元测试! -
跨平台核心:我们的主要目标是最终支持尽可能多的平台。从 iOS/UIKit 和 macOS/AppKit 的基本支持开始,我们计划在未来的版本中添加 WebAssembly/DOM 和原生 Android 的渲染器。由于核心 API 是跨平台的,使用 Tokamak 编写的 UI 组件不需要更改就可以在新添加的平台中可用,除非你需要特定于设备或操作系统的 UI 逻辑。如果需要的话,你可以通过简单的组合来干净地分离特定于平台的组件。
-
经过验证的架构:React 已存在多年,获得了巨大的关注,并且仍在增长。我们看到了许多应用程序成功地用其重建,并且对 React 本身也听到了积极的反馈,但我们也看到了很多关于对其过度依赖 JavaScript 的抱怨。Tokamak 将 React 的经过验证的模式带入了 Swift 中,使其对您可用。
重要:截至目前,Tokamak 相对稳定,也就是说维护者没有意识到有任何阻止性或关键性错误。组件和 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
- 从Finder或从终端打开
Example
工作区
open -a Xcode *.xcworkspace
- 为iOS构建可执行目标
TokamakDemo-iOS
和为macOS构建TokamakDemo-macOS
标准组件
Tokamak提供了一些基本的组件,您可以在您的应用程序中重复使用。在iOS上,这些组件被渲染为与您已经熟悉的对应的UIView
子类,例如Button
组件被渲染为UIButton
,Label
作为UILabel
等。更多详情请查看完整最新的组件列表。
快速入门
我们试图使Tokamak的API尽可能简单,核心算法及其支持的协议/结构目前只有约600行代码。它建立在几个基本概念之上。
属性
Props
描述了希望在用户屏幕上看到的内容的“配置”。一个例子可能是一个描述背景颜色、布局、初始值等的struct
。Props
是不可变的,并且是Equatable
的,这允许我们在它们发生变化时进行观察。您始终使用struct
或enum
,而 never使用class
作为props,以确保不可变性。您不需要为Props
提供自己的Equatable
实现,因为Swift编译器可以在幕后自动生成一个实现。(更多信息请参阅这里)下面是一个简单的可用于自己的组件(如上图中Counter
示例)的Props
struct。
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提供的每个组件,都有一个简单的方式来创建一个与之关联的节点,并附带给定的属性和子组件
// 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
}
当 Tokamak 组件的 Props
或从父组件传递的 Children
发生变化时,Tokamak 会调用 render
方法。您无需自己调用 render
,只需向从父 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"))
这将在屏幕上呈现一个带有5次重复文本"repeated"
的标签。
叶组件
一些组件可能根本不需要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
参数。
挂钩
您经常需要具有状态或引起某些其他副作用的组件。hooks
提供了一种清晰的方式将声明式组件与其他指令性代码(如状态管理、文件I/O、网络等)区分开来。
在Tokamak中,标准协议CompositeComponent
将hooks
注入为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)
}
}
最简单的端钩之一是 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
的,它们永远不会在同步方式下就地更新组件状态,而只是在稍后通过Tokamak计划更新。只有调用render
才会在具有hooks.state
的组件上安排。
当需要状态变化来更新任何子组件时,您可以将状态值传递到从render
返回的节点的props或children中。在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提供了一种很好的方式来组合副作用,并将它们与组件代码分开。您总是可以通过重用现有的hooks来创建自己的hook:只需将它添加到您的extension Hooks
中,使其最适合您。
渲染器
当将Tokamak架构映射到iOS上之前建立的内容时,Component
对应于“视图模型”层,而Hooks
提供可重用的“控制器”层。根据这个术语,《Renderer》是“视图”层,但它完全由Tokamak管理。这不仅大大简化了您组件的代码,还允许您使其声明性,而且完全解耦了平台特定代码。
请注意,上面提到的Counter
组件不包含来自UIKit
模块的单个类型,尽管组件本身是通过其TokamakViewController
公共API传递给特定的UIKitRenderer
以使其在使用UIKit
的应用中可用的。在其他平台上,您可以使用不同的渲染器,而组件代码如果不需为该环境更改行为,则保持不变。否则,您可以通过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 或 algebraic effects 的模拟,它们为 React 中的 Hooks 提供了灵感来源。遗憾的是,由于 Swift 目前的限制,我们无法原生地表示 monads 或 algebraic effects,因此 Hooks 需要应用一些限制以确保其正常工作。类似的限制也适用于 React 中的 Hooks。
- 您可以从任何组件的
render
函数中调用 Hooks。👍 - 您可以从您的自定义 Hooks (在
Extensions
中的Hooks
定义的) 中调用 Hooks。🙌 - 不要在循环、条件或嵌套函数/闭包中调用 Hooks。
🚨 - 不要从任何不是组件上
static func render
的函数或不是自定义 Hooks 的函数中调用 Hooks。⚠️
在未来的版本中,Tokamak 将提供一种在编译时能够捕捉 Rules of Hooks 违反的 linting 工具。
钩子规则为何存在?
与React相同,Tokamak为每个有状态组件维护一个"记忆单元"数组来存储实际状态。它需要在执行组件的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为何使用值类型和协议而不是类?
Swift开发者可能因为UIKit
和AppKit
中丰富的类层次结构而习惯于使用类(尽管Apple曾强调过组合优于继承的优势)。不幸的是,虽然UIKit
是相对较新的开发,但它仍然遵循了许多AppKit
中使用的模式,而AppKit
本身在20世纪80年代后期就已经建立。这两个都是为Objective-C而设计的,在Swift成为公众之前好几年,而且以协议为基础的模式也得到了发展。
Tokamak
的一个主要目标是为Swift构建一个感觉上是本地的UI框架。当与类基础的API相比,Tokamak的API带来了以下好处
- 无需将子类
NSObject
来遵循常用的协议; - 无需使用
override
并记住调用super
; - 无需使用
required init
、convenience init
或关注严格的类初始化规则; - 您无法通过不可变值创建引用循环,在使用回调时无需执行
weakSelf/strongSelf
舞蹈; - 您无需担心在不同的作用域中意外捕获的通过引用修改对象:不可变值默认被复制,并且在编译优化期间大多数复制都会被移除;
- 关注组合优于继承:当您只需进行简单的自定义时,无需子类化
UIViewController
或UIView
以及上述所有问题。 - 关注功能性声明式编程,同时在需要时允许使用命令式代码:值类型保证了纯函数没有意外的副作用。
是否有什么像JSX一样可用于Tokamak的东东?
目前还没有,但相对于React的React.createElement
语法,我们发现在Tokamak中创建节点要简洁得多。事实上,在使用Tokamak的.node
API时,您不需要像JSX那样写出闭合元素标签。例如,比较这个
Label.node("label!")
到这个
<Label>label!</Label>
我们承认对于属性和属性初始化器参数的顺序有开销。对于后者,Tokamak有一个有用的约定,即所有属性初始化器的命名参数都应该按字母顺序排列。
主要问题是当前没有易于扩展的Swift解析器或宏系统,这允许使用类似JSX的组件。一旦变得容易实现,我们肯定会考虑它作为一个选项。
Component
协议上render
函数是static
的?
为什么在如果采取类似于这种框架API设计的替代方法,我们可以将组件定义为普通函数,这样就不需要是static
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
带static
函数的协议和结构使我们能够绕过这个问题,并用协议和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文件。