Tempura是对iOS开发的全面方法,它通过Katana借用Redux的概念,并通过MVVM。
🎯 安装
Tempura可通过CocoaPods获得。
要求
- iOS 11+
- Xcode 11.0+
- Swift 5.0+
CocoaPods
CocoaPods是一个Cocoa项目的依赖管理器。您可以使用以下命令安装它
$ sudo gem install cocoapods
要使用CocoaPods在您的Xcode项目中集成Tempura,您需要创建一个包含如下内容的Podfile
use_frameworks!
source 'https://cdn.cocoapods.org/'
platform :ios, '11.0'
target 'MyApp' do
pod 'Tempura'
end
现在,只需运行
$ pod install
Swift包管理器
从版本 9.0.0
开始,Tempura
也支持 Swift Package Manager (SPM)。
🤔 我应该为什么使用这个?
Tempura 允许你
- 模拟你的应用状态
- 定义可以改变它的操作
- 创建 UI
- 享受状态和 UI 之间的自动同步
- 发布,迭代
我们开始在 Bending Spoons 内部的小团队中使用 Tempura。它对我们来说效果非常好,我们最终开发了和维护了二十多个高质量的 app,去年有超过 1000 万活跃用户使用这种方法。崩溃率和发展时间下降,用户参与度和质量上升。我们非常满意,因此想与 iOS 社区分享,希望你会像我们一样激动。
👩💻 给我看代码
Tempura 使用 Katana 来处理你应用的逻辑。你的应用状态在单个结构体中定义。
struct AppState: State {
var items: [Todo] = [
Todo(text: "Pet my unicorn"),
Todo(text: "Become a doctor.\nChange last name to Acula"),
Todo(text: "Hire two private investigators.\nGet them to follow each other"),
Todo(text: "Visit mars")
]
}
你只能通过 State Updater 来操作状态。
struct CompleteItem: StateUpdater {
var index: Int
func updateState(_ state: inout AppState) {
state.items[index].completed = true
}
}
用于渲染屏幕 UI 的状态部分由 ViewModelWithState 选择。
struct ListViewModel: ViewModelWithState {
var todos: [Todo]
init(state: AppState) {
self.todos = state.todos
}
}
你应用每个屏幕的 UI 都由一个 ViewControllerModellableView 组成。它公开回调(我们称之为交互),以表示用户已发生操作。它基于 ViewModelWithState 进行渲染。
class ListView: UIView, ViewControllerModellableView {
// subviews
var todoButton: UIButton = UIButton(type: .custom)
var list: CollectionView<TodoCell, SimpleSource<TodoCellViewModel>>
// interactions
var didTapAddItem: ((String) -> ())?
var didCompleteItem: ((String) -> ())?
// update based on ViewModel
func update(oldModel: ListViewModel?) {
guard let model = self.model else { return }
let todos = model.todos
self.list.source = SimpleSource<TodoCellViewModel>(todos)
}
}
每个屏幕都由一个 ViewController 管理。默认情况下,它将自动监听状态更新,并保持 UI 同步。ViewController 的唯一其他责任是监听来自 UI 的交互并将操作分发给状态。
class ListViewController: ViewController<ListView> {
// listen for interactions from the view
override func setupInteraction() {
self.rootView.didCompleteItem = { [unowned self] index in
self.dispatch(CompleteItem(index: index))
}
}
}
注意,视图控制器的 dispatch
方法与 Katana store 暴露的方法略有不同:它接受一个简单的 Dispatchable
,并且不返回任何内容。这是为了避免在视图控制器中实现逻辑。
如果你的交互处理程序需要做更多的事情,你应该将所有这些逻辑打包成一个副作用并分发它。
对于那些在视图控制器中需要使用一些逻辑的情况(例如,当你不想完全重构所有逻辑而更新旧应用时),你可以使用以下方法
open func __unsafeDispatch<T: StateUpdater>(_ dispatchable: T) -> Promise<Void>
open func __unsafeDispatch<T: ReturningSideEffect>(_ dispatchable: T) -> Promise<T.ReturningValue>
但是请注意,使用这些方法是被高度不推荐的,并且它们将在未来的版本中删除。
导航
真正的应用是由多个屏幕组成的。如果屏幕需要展示另一个屏幕,其 ViewController 必须符合 RoutableWithConfiguration 协议。
extension ListViewController: RoutableWithConfiguration {
var routeIdentifier: RouteElementIdentifier { return "list screen"}
var navigationConfiguration: [NavigationRequest: NavigationInstruction] {
return [
.show("add item screen"): .presentModally({ [unowned self] _ in
let aivc = AddItemViewController(store: self.store)
return aivc
})
]
}
}
然后你可以通过 ViewController 中的导航操作之一来触发展示。
self.dispatch(Show("add item screen"))
更多关于导航的信息 这里
ViewController嵌套
您可以将ViewController嵌套在其他 ViewController 中。当您想重用部分 UI 和逻辑时,这非常有用。要做到这一点,在父 ViewController 中,您需要提供一个将接收子 ViewController 视图的 ContainerView
。
class ParentView: UIView, ViewControllerModellableView {
var titleView = UILabel()
var childView = ContainerView()
func update(oldModel: ParentViewModel?) {
// update only the titleView, the childView is managed by another VC
}
}
然后,在父 ViewController 中,您只需添加子 ViewController
class ParentViewController: ViewController<ParentView> {
let childVC: ChildViewController<ChildView>!
override func setup() {
self.childVC = ChildViewController(store: self.store)
self.add(childVC, in: self.rootView.childView)
}
}
所有自动化都会自动完成。现在您将在 ParentViewController
中有一个 ChildViewController
,ChildViewController 的视图将嵌套在 childView
中。
📸 UI快照测试
Tempura 具有一个快照测试系统,可以用来获取您视图在所有可能状态下的截图,包括所有设备和所有支持的语言。
使用方法
您需要在应用的测试目标中包含 TempuraTesting
pod。
target 'MyAppTests' do
pod 'TempuraTesting'
end
在您的 plist
中指定截图的位置。
UI_TEST_DIR: $(SOURCE_ROOT)/Demo/UITests
在 Xcode 中创建一个新的 UI 测试用例类
文件 -> 新建 -> 文件... -> UI 测试用例类
在这里,您可以使用 test
函数来获得一个带有特定 ViewModel
的 ViewControllerModellableView
的快照。
import TempuraTesting
class UITests: XCTestCase, ViewTestCase {
func testAddItemScreen() {
self.uiTest(testCases: [
"addItem01": AddItemViewModel(editingText: "this is a test")
])
}
}
标识符将定义文件系统中快照图像的名称。
您还可以使用上下文参数自定义视图的渲染方式(例如,您可以将视图嵌入到 UITabBar 实例中)。以下是一个将视图嵌入到标签栏中的示例
import TempuraTesting
class UITests: XCTestCase, ViewTestCase {
func testAddItemScreen() {
var context = UITests.Context<AddItemView>()
context.container = .tabBarController
self.uiTest(testCases: [
"addItem01": AddItemViewModel(editingText: "this is a test")
], context: context)
}
}
如果 UIScrollView 中的重要内容没有完全显示,您可以使用 scrollViewsToTest(in view: V, identifier: String)
方法。这将产生一个额外的快照,渲染每个返回的 UIScrollView 实例的全部内容。
在这个示例中,我们使用 scrollViewsToTest(in view: V, identifier: String)
来拍摄屏幕底部 情感选择器 的扩展快照。
func scrollViewsToTest(in view: V, identifier: String) -> [String: UIScrollView] {
return ["mood_collection_view": view.moodCollectionView]
}
如果您需要在渲染 UI 并截取快照之前等待异步操作的完成,您可以使用 isViewReady(view:identifier:)
方法。例如,在这里我们等待一个假设显示远程 URL 图片的视图准备好。当图片显示出来(即状态是 loaded
),然后截取快照。
import TempuraTesting
class UITests: XCTestCase, ViewTestCase {
func testAddItemScreen() {
self.uiTest(testCases: [
"addItem01": AddItemViewModel(editingText: "this is a test")
])
}
func isViewReady(_ view: AddItemView, identifier: String) -> Bool {
return view.remoteImage.state == .loaded
}
}
一旦截取了快照,测试就会通过。
上下文
您可以通过传递给 uiTest
方法的 context
对象启用一些高级功能。
container
允许您在 UITests 中将 VC 定义为一个视图的容器。基本的navigationController
和tabBarController
已经提供,或者您可以使用custom
定义自己的。hooks
允许在发生某些生命周期事件时执行操作。可用的钩子有viewDidLoad
、viewWillAppear
、viewDidAppear
、viewDidLayoutSubviews
和navigationControllerHasBeenCreated
。screenSize
和orientation
属性允许您在测试期间定义自定义屏幕大小和方向。renderSafeArea
允许您定义是否在测试期间将安全区域渲染为半透明的灰色覆盖层。keyboardVisibility
允许您定义是否应将灰色覆盖层渲染为键盘的占位符。
多台设备
默认情况下,测试仅在您从 Xcode(或您的设备,或 CI 系统)选择的设备上进行。我们可以通过以下脚本等脚本来在所有设备上运行快照。
xcodebuild \
-workspace <project>.xcworkspace \
-scheme "<target name>" \
-destination name="iPhone 5s" \
-destination name="iPhone 6 Plus" \
-destination name="iPhone 6" \
-destination name="iPhone X" \
-destination name="iPad Pro (12.9 inch)" \
test
将在所有设备上并行运行测试。如果您想更改行为,请参考 xcodebuild
文档。
如果您想在 UI 测试中测试特定的语言,可以将 test
命令替换为 -testLanguage <iso code639-1>
。应用将以该语言启动,并对该地区执行 UITesting。例如
xcodebuild \
-workspace <project>.xcworkspace \
-scheme "<target name>" \
-destination name="iPhone 5s" \
-destination name="iPhone 6 Plus" \
-destination name="iPhone 6" \
-destination name="iPhone X" \
-destination name="iPad Pro (12.9 inch)" \
-testLanguage it
远程资源
UI 需要显示远程内容(即远程图片、远程视频等)的情况很常见。在执行 UITests 时,这可能会成为问题,因为
- 测试可能会因为网络或服务器问题而失败。
- 系统应在远程资源加载时跟踪,然后将它们放入 UI 中,然后再截图。
为了解决这个问题,Tempura 提供了一个名为 LocalFileURLProtocol
的 URLProtocol 子类,它尝试从您的本地存档加载远程文件。
想法是将所有需要渲染 UI 的资源放入您的(测试)存档中,LocalFileURLProtocol
将尝试加载它们,而不是执行网络请求。
对于给定的 URL,LocalFileURLProtocol
将使用以下规则匹配文件名:
- 搜索具有 URL 作为名称的文件(例如,http://example.com/image.png)
- 搜索具有最后一个路径组件作为文件名的文件(例如,image.png)
- 搜索具有没有扩展名的最后一个路径组件作为文件名的文件(例如,image)
如果无法检索到匹配的文件,则执行网络调用。
为了在您的应用中注册 LocalFileURLProtocol
,您需要在测试的生命周期中尽可能早地调用以下API。
URLProtocol.registerClass(LocalFileURLProtocol.self)
注意,如果您正在使用 Alamofire,这可能不会按预期工作。您可以在这里找到一个相关问题和配置Alamofire以处理URLProtocol
类的链接。
使用 ViewController 容纳进行 UI 测试
ViewTestCase
专注于测试 ViewControllerModellableView
,它通过自动注入代表测试条件的 ViewModel
来实现。
如果您使用 ViewController 容纳(如上例中的 ParentView
),那么在注入 ViewModel 时,有一部分视图将无法更新,因为还有另一个 ViewController 负责该部分。
在这种情况下,您需要使用 ViewControllerTestCase
协议在 ViewController 的级别上进行扩展和测试。
class ParentViewControllerUITest: XCTestCase, ViewControllerTestCase {
/// provide the instance of the ViewController to test
var viewController: ParentViewController {
let fakeStore = Store<AppState, EmptySideEffectDependencyContainer>()
let vc = ParentViewController(store: testStore)
return vc
}
/// define the ViewModels
let viewModel = ParentViewModel(title: "A test title")
let childVM = ChildViewModel(value: 12)
/// define the tests we want to perform
let tests: [String: ParentViewModel] = [
"first_test_vc": viewModel
]
/// configure the ViewController with ViewModels, also for the children VCs
func configure(vc: ParentViewController, for testCase: String, model: ParentViewModel) {
vc.viewModel = model
vc.childVC.viewModel = childVM
}
/// execute the UI tests
func test() {
let context = UITests.VCContext<ParentViewController>(container: .none)
self.uiTest(testCases: self.tests, context: context)
}
}
如果没有任何子 ViewController 需要配置,那么这甚至更容易,您无需提供 configure(:)
方法。
class ParentViewControllerUITest: XCTestCase, ViewControllerTestCase {
/// provide the instance of the ViewController to test
var viewController: ParentViewController {
let fakeStore = Store<AppState, EmptySideEffectDependencyContainer>()
let vc = ParentViewController(store: testStore)
return vc
}
/// define the ViewModel
let viewModel = ParentViewModel(title: "A test title")
/// define the tests we want to perform
let tests: [String: ParentViewModel] = [
"first_test_vc": viewModel
]
/// execute the UI tests
func test() {
let context = UITests.VCContext<ParentViewController>(container: .tabbarController)
self.uiTest(testCases: self.tests, context: context)
}
}
🧭 下一步要做什么
示例应用
此存储库包含一个使用 Tempura 实现的待办事项列表应用的演示。要生成 Xcode 项目文件,您可以使用 Tuist。运行 tuist generate
,打开项目并运行 Demo
目标。
查看文档
📄 Swift版本
Tempura 的某些版本只支持某些 Swift 版本的 Swift。根据您的项目使用哪个版本的 Swift,您应该使用特定版本的 Tempura。使用此表检查您需要哪个版本的 Tempura。
Swift版本 | Tempura 版本 |
---|---|
Swift 5.0 | Tempura 4.0+ |
Swift 4.2 | Tempura 3.0 |
Swift 4 | Tempura 1.12 |
📬 取得联系
如果您有任何 问题 或 反馈,我们很乐意在 [email protected] 听到您的声音。
🙋♀️ 做出贡献
- 如果您 发现了错误,请提交一个问题;
- 如果您有 功能请求,请提交一个问题;
- 如果您 想贡献代码,请提交一个 Pull Request;
- 如果您 有一个想法 可以改进框架或推广,请 联系。
- 如果您想在项目中尝试这个框架或编写演示程序,请发送我们该仓库的链接。
👩⚖️ 许可证
Tempura 在MIT 许可证下可用。
❓ 关于
Tempura 由Bending Spoons维护。我们创建了自家的技术产品,这些产品被全世界数百万用户使用和喜爱。听起来很酷吗?看看我们