Tokamak
🛠 ⚛️ 📲
纯 Swift 编写的类似 React 的原生 UI 框架
Status provides a declarative, testable and scalable API for building UI components with complete native views. With a minimal effort, you can use it for your new iOS apps or add it to existing apps without rewriting the rest of the code or changing the app's overall architecture.
托卡马克重新创建了 React Hooks API,通过 Swift 的强类型系统、高性能和有效的内存管理而得到了改进,因为它是编译成原生二进制的。
与标准的 UIKit MVC 或在其之上构建的其他模式(MVVM、MVP、VIPER 等)相比,托卡马克提供
-
原生 UI 的声明性DSL:不再由 Storyboards 引起冲突,没有模板语言或 XML。在 Swift 中简洁地描述应用程序的 UI,并获取具有完整辅助功能、自动布局和原生导航手势的原生视图。
-
易于使用的单向数据绑定:厌倦了
didSet
、代理、通知或 KVO?UI 组件会自动更新状态变化。 -
干净的组合架构:组件可以作为子组件传递给其他组件,并有一个专注于代码重用的 API。您可以将 Tokamak 组件轻松嵌入到现有的 UIKit 代码中,反之亦然:将代码暴露为 Tokamak 组件。无需决定是否应该从
UIView
或UIViewController
子类化以使 UI 可组合。 -
离屏渲染进行单元测试:无需维护缓慢和不可靠的 UI 测试,在模拟器屏幕上渲染一切,并模拟实际的触摸事件,仅测试 UI 逻辑。使用托卡马克编写的组件可以在离屏上进行测试,测试在几秒钟内完成。如果您的 UI 不需要任何特定的
UIKit
代码(并且托卡马克提供了实现该目标的辅助工具),甚至可以在 Linux 上运行与 UI 相关的单元测试! -
跨平台核心:我们的主要目标是最终支持尽可能多的平台。从 iOS/UIKit 和对 macOS/AppKit 的基本支持开始,我们计划在未来版本中添加 WebAssembly/DOM 和原生 Android 的渲染器。由于核心 API 兼容多个平台,因此使用 Tokamak 编写的 UI 组件不需要更改,就在新添加的平台中可用,除非您需要针对特定设备或操作系统的 UI 逻辑。并且如果需要,您也可以通过简单的组合清晰地区分特定平台的组件。
-
经过验证的架构:React 已存在多年,已经获得了大量关注,并且仍在增长。我们看到了许多使用它成功重构的应用,并听到了对 React 本身的积极评价,但我们也看到了很多对它过度依赖 JavaScript 的抱怨。Tokamak 通过其已建立的模式在 Swift 中将 React 架构提供给您。
重要:截至目前,Tokamak 相对稳定,因为没有出现维护者知晓的任何阻止或关键错误。 Component
和 Hooks
类型的主要 API 已被冻结,并且有许多 标准组件 可以用来在 iOS 上开始构建有用的应用。macOS/AppKit 渲染器仅支持最基本的组件,提高其与 iOS 渲染器功能对等性是首要任务。如果在未来有绝对必要的破坏性更改,我们计划以源兼容的方式弃用旧 API,并逐步引入替代方案。重要的是要注意,无法总是避免源破坏性更改,但它们将通过适当的版本号更改和迁移指南反映出来。
别忘了查看 Spectrum 上的 Tokamak 社区 并留下您的反馈、评论和问题!
目录
示例代码
绑定按钮到标签的 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
Props
描述了用户屏幕上所需看到的“配置”。例如,可以是描述背景颜色、布局、初始值等的struct
。Props是不可变的且是Equatable
的,这使得我们能够观察它们何时更改。您始终使用struct
或enum
,永远不使用class
来为props,以确保不可变性。您不需要为props提供自己的Equatable
实现,因为Swift编译器可以在幕后自动为您生成一个。以下是一个简单的用于您自己的组件如Counter
的例子
struct Props: Equatable {
let countFrom: Int
}
子元素
有时,“配置”会以树形结构来描述。例如,视图列表包含子视图的数组,而这些子视图自身也可以包含其他子视图。在Tokamak中,这被称为Children
,它们的行为类似于Props
,但重要到足以单独处理。Children
也是不可变的且是Equatable
,这使得我们也可以观察其变化。
组件
Component
是一个协议,它将给定的Props
和Children
与屏幕上的内容耦合,并提供了如何在屏幕上渲染它们的声明。
protocol Component {
associatedtype Props: Equatable
associatedtype Children: Equatable
}
(不用担心你不知道associatedtype
是什么,它只是组件提供这些类型并使它们Equatable
的简单要求。如果你知道什么是相关类型,你也不必担心。)
节点
节点是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
}
Konamak会在父组件传递的 Props
或 Children
发生变化时调用组件的 render
方法。您永远不需要自己调用 render
,只需将不同的值作为 Props
或 Children
传递给父组件返回的节点,Konamak就只会更新屏幕上需要更新的那些视图。
请注意,render
函数 **不返回其他** 组件,它 **返回描述其他组件的节点**。这是一个非常重要的区分度,它允许Konamak保持高效,并避免更新组件的深层树。
下面是一个简单组件的示例,该组件将它的子组件垂直堆叠,堆叠次数与通过 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
,对于这些组件,Konamak提供了一个 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)
相当经常地,您需要具有状态或产生某些其他 副作用 的组件。钩子(Hooks)提供了声明性组件与其他命令式代码,如状态管理、文件I/O、网络等之间的明确分离。
Konamak中的标准协议 CompositeComponent
得到了作为参数注入到 render
函数中的钩子(Hooks)。
protocol CompositeComponent: Component {
static func render(
props: Props,
children: Children,
hooks: Hooks
) -> AnyNode
}
实际上,标准的 PureComponent
是一个不使用钩子(Hooks)进行渲染的 CompositeComponent
的特例。
// 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 更新。只有在获得此状态的组件通过 hooks.state
调用 render
时才会安排调用。
当你需要将状态变化用于更新任何子组件时,你可以通过从 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 提供了组合副作用和将它们与组件代码分离的出色方式。你可以始终创建自己的 hook 以重用现有 hook:只需将其添加到您的 extension Hooks
中,使其对您最有利。
渲染器
当将 Tokamak 的架构映射到以前在 iOS 中建立的内容时,Component
对应于一个“视图模型”层,而 Hooks
提供了可重用的“控制器”层。在这些意义上,一个 Renderer
是“视图”层,但它由 Tokamak 完全管理。这不仅极大地简化了组件的代码,并允许你使其声明性,而且还完全解耦了平台特定代码。
请注意,上面的 Counter
组件不包含来自 UIKit
模块的任何类型,尽管组件本身是通过其 TokamakViewController
公共 API 传递给特定的 UIKitRenderer
,以便在一个使用 UIKit
的应用程序中使用。在其他平台上,你可以使用不同的渲染器,而组件代码可以保持不变,如果它的行为不需要为此环境改变的话。否则,你可以通过 Props
调整组件的行为,并传递根据渲染器的平台不同的“初始化”props。
将在未来为其他平台提供渲染器是我们的首要任务之一。Tokamak 已经在 TokamakAppKit
模块中提供了对 macOS 应用基本支持,该模块允许你在不对组件代码进行任何更改的情况下,在 iOS 和 macOS 上渲染相同的标准组件,无需使用 Marzipan!
要求
- 使用
TokamakUIKit
的 iOS 11.0 或更高版本 - 使用
TokamakAppKit
的 macOS 10.14 - Xcode 10.1 或更高版本
- Swift 4.2
安装
CocoaPods
CocoaPods是一个针对Swift和Objective-C Cocoa项目的依赖管理器。您可以使用以下命令安装它
$ gem install cocoapods
转到项目目录,并使用以下命令创建Podfile
$ pod install
在您的Podfile
内部,指定Tokamak
库
# 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,因此需要施加一些限制来使其生效。对React中的Hooks也应用了类似限制
- 您可以从任意组件的
render
函数中调用Hooks。👍 - 您可以从您的自定义Hooks(由您在
Hooks
的extension
中定义)中调用Hooks。🙌 - 不要在循环、条件或嵌套函数/closure中调用Hooks。
🚨 - 不要从任何不是组件上的
static func render
函数或自定义Hooks的函数中调用Hooks。⚠️
在未来版本中,Tokamak将提供能够在编译时捕获Hooks规则违规的lint工具。
为什么存在Hooks规则?
和React类似,Tokamak为每个有状态的组件维护一个“记忆单元”数组以保存实际状态。在执行组件的render
函数时,它需要区分一个Hooks调用与另一个以便将它们映射到相应的单元。考虑以下内容
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
时知道我们真正处理的是哪个状态?它依赖于调用顺序,所以如果顺序在渲染之间发生变化,你可能会意外地得到一个状态单元的值,而不是你期望的不同状态单元的值。
我们鼓励你将任何Hooks逻辑放在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
(尽管之前Apple曾经在Cocoa程序中组合优于继承的好处中有过强调)中有大量类层次结构,Swift的开发者可能习惯了类。遗憾的是,尽管UIKit
是一个相对较新的发展,但它仍然密切遵循AppKit
中使用的许多模式,而后者本身在20世纪80年代末就已经构建。这些都是在Swift公开和协议导向模式确立之前用Objective-C开发的。
Tokamak的一个主要目标是构建一个感觉像Swift原生的UI框架。与基于类的API相比,Tokamak的API带来了以下优势
- 无需将自定义类
NSObject
子类化以遵守常用协议; - 无需使用
override
并记得调用super
; - 无需
required init
、convenience init
或关心严格的类初始化规则; - 你无法通过不可变值创建引用周期,因此当使用回调时无需
weakSelf/strongSelf
之舞; - 你无需担心在不同的作用域中意外捕获到不同作用域的对象:不可变值会隐式复制,而且大多数复制在编译器优化期间会被删除。
- 重点在于组合而非继承:当只需要简单的自定义时,无需继承
UIViewController
或UIView
,也无需担心上述所有内容; - 注重函数式和声明式编程,同时在必要时仍可使用命令式代码:值类型可确保纯函数中没有意外的副作用。
JSX 的功能可以用在 Tokamak 上?
有没有什么类似于截至目前,答案是还没有,但我们发现与 React.createElement
语法相比,Tokamak 的 API 允许您更简洁地创建节点。事实上,使用 Tokamak 的 .node
API,您不需要像 JSX 那样编写关闭标签。例如,比较以下这段:
Label.node("label!")
与这段:
<Label>label!</Label>
我们确实同意,对属性和方法而言存在一些 .init
过剩,以及要求属性初始化器参数必须按顺序排列。对于后者,Tokamak 有一个有用的约定,即所有的属性初始化器命名参数都应按照字母顺序排列。
主要问题是,目前还没有可轻松扩展的 Swift 解析器或宏系统可用,以便为 Tokamak 应用类似于 JSX 的功能。一旦实现起来变得简单,我们肯定会考虑将其作为选项之一。
render
函数在 Component
协议中是静态的?
为什么 我们可以采用一种不同的方法来设计框架的 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
协议中移除 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 的人们 构建于 JavaScript 之上的实用且优雅的 UI 框架。
😄 - 感谢 Render、ReSwift、Katana UI 和 Komponents 的启发!
贡献
本项目遵循 贡献者公约行为准则。通过参与,您将被期待遵守此准则。请将不可接受的行为报告给 [email protected]。
维护者
Max Desiatov,Matvii Hodovaniuk
许可证
Tokamak 的许可协议为 Apache 2.0。更多详细信息请参阅LICENSE 文件。