TokamakAppKit 0.1.2

TokamakAppKit 0.1.2

Max Desiatov 维护。



Tokamak

纯 Swift 编写的类似 React 的原生 UI 框架🛠⚛️📲

CI Status Coverage Version License Platform Join the community on Spectrum

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 组件。无需决定是否应该从 UIViewUIViewController 子类化以使 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 相对稳定,因为没有出现维护者知晓的任何阻止或关键错误。 ComponentHooks 类型的主要 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))
  }
}

Counter component

或者类似地,它也可以添加到 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))
    )
  }
}

请注意,我们添加了明确的约束,将其用作窗口的主视图控制器,而窗口默认没有固定的预定义大小。

Counter component

示例项目

尝试Tokamak的最佳方式是运行示例项目

  1. 请确保您已安装CocoaPods和Xcode 10.1或更高版本
pod --version
xcode-select -p
  1. 克隆仓库
git clone https://github.com/MaxDesiatov/Tokamak
  1. 在示例项目中安装依赖关系
cd Tokamak/Example
pod install
  1. 从Finder或从终端打开Example工作空间
open -a Xcode *.xcworkspace
  1. 为iOS构建可执行目标TokamakDemo-iOS和为macOS构建TokamakDemo-macOS

标准组件

Tokamak提供了一些基本组件,您可以在您的应用程序中重用。在iOS上,这些组件被渲染为对应的您已经熟悉的UIView子类,例如Button组件被渲染为UIButtonLabel作为UILabel等。查看完整的最新列表以获取更多信息。

快速入门

我们试图让Tokamak的API尽可能简单,核心算法以及支持协议/结构体目前仅占用约600行代码。所有这些都基于几个基本概念

Props

Props描述了用户屏幕上所需看到的“配置”。例如,可以是描述背景颜色、布局、初始值等的struct。Props是不可变的且是Equatable的,这使得我们能够观察它们何时更改。您始终使用structenum,永远不使用class来为props,以确保不可变性。您不需要为props提供自己的Equatable实现,因为Swift编译器可以在幕后自动为您生成一个。以下是一个简单的用于您自己的组件如Counter的例子

struct Props: Equatable {
  let countFrom: Int
}

子元素

有时,“配置”会以树形结构来描述。例如,视图列表包含子视图的数组,而这些子视图自身也可以包含其他子视图。在Tokamak中,这被称为Children,它们的行为类似于Props,但重要到足以单独处理。Children也是不可变的且是Equatable,这使得我们也可以观察其变化。

组件

Component是一个协议,它将给定的PropsChildren与屏幕上的内容耦合,并提供了如何在屏幕上渲染它们的声明。

protocol Component {
  associatedtype Props: Equatable
  associatedtype Children: Equatable
}

(不用担心你不知道associatedtype是什么,它只是组件提供这些类型并使它们Equatable的简单要求。如果你知道什么是相关类型,你也不必担心。)😄Tokamak的API是专门设计来隐藏PATs的“尖角”从公共API中,并使其易于使用,而无需需要高级的Swift知识。这与Swift标准库所做得很像,它建立在PATs之上,但仍保持灵活和易于使用。

节点

节点是PropsChildren的容器,并且遵循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), [])

渲染函数

最简单的组件是一个仅接受 PropsChildren 作为参数并返回节点树的函数,这种函数称为“纯函数”

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会在父组件传递的 PropsChildren 发生变化时调用组件的 render 方法。您永远不需要自己调用 render,只需将不同的值作为 PropsChildren 传递给父组件返回的节点,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 monadsalgebraic effects的模拟,它们是React中Hooks的灵感来源。遗憾的是,由于Swift当前的限制,我们无法原生地表达monads或algebraic effects,因此需要施加一些限制来使其生效。对React中的Hooks也应用了类似限制

  1. 您可以从任意组件的render函数中调用Hooks。👍
  2. 您可以从您的自定义Hooks(由您在Hooksextension中定义)中调用Hooks。🙌
  3. 不要在循环、条件或嵌套函数/closure中调用Hooks。🚨
  4. 不要从任何不是组件上的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使用值类型和协议而不是类?

由于在UIKitAppKit(尽管之前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之舞
  • 你无需担心在不同的作用域中意外捕获到不同作用域的对象:不可变值会隐式复制,而且大多数复制在编译器优化期间会被删除。
  • 重点在于组合而非继承:当只需要简单的自定义时,无需继承 UIViewControllerUIView,也无需担心上述所有内容;
  • 注重函数式和声明式编程,同时在必要时仍可使用命令式代码:值类型可确保纯函数中没有意外的副作用。

有没有什么类似于 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 来管理。

致谢

贡献

本项目遵循 贡献者公约行为准则。通过参与,您将被期待遵守此准则。请将不可接受的行为报告给 [email protected]

维护者

Max DesiatovMatvii Hodovaniuk

许可证

Tokamak 的许可协议为 Apache 2.0。更多详细信息请参阅LICENSE 文件。