Tempura 是一种全面的 iOS 开发方法,它借鉴了 Redux(通过 Katana)和 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之间的自动同步
- 发货,迭代
我们开始在弯曲勺子的小团队内部使用Tempura。它为我们工作得非常好,以至于我们最终开发并维护了超过二十个高质量的APP,去年有超过1000万活跃用户使用这种方法。崩溃率和开发时间下降,用户参与度和质量提高。我们非常满意,想与iOS社区分享这一消息,希望您也能像我们一样兴奋。
👩💻 给我看看代码
Tempura使用Katana来处理应用逻辑。APP状态由单个结构体定义。
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")
]
}
您只能通过状态更新器操作状态。
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))
}
}
}
注意,ViewController的dispatch
方法与由 Katana store 公开的略有不同:它接受简单的Dispatchable
且不返回任何内容。这样做是为了避免在ViewController中实现逻辑。
如果交互处理程序需要执行多项操作,请将所有这些逻辑打包在副作用中并分发。
对于极少数需要在小控制台中包含一些逻辑的情况(例如在无需完全重构所有逻辑的情况下更新旧应用)您可以使用以下方法:
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中您需要提供一个ContainerView
,它将接收子ViewController的视图作为子视图。
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
,子ViewController的视图将托管在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")
])
}
}
标识符将定义文件系统中快照图像的名称。
您还可以通过上下文参数个性化视图渲染方式(例如,您可以嵌套视图到UINavigationController实例中)。以下是一个将视图嵌入到标签栏中的示例。
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>
。应用将以此语言启动,并使用该区域设置执行 UITests。例如:
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 提供了一个名为 URLProtocol 的子类 LocalFileURLProtocol
,它尝试从您的本地包加载远程文件。
想法是在您的(测试)包中放入所有需要渲染 UI 的资源,而 LocalFileURLProtocol
将尝试加载它们而不是进行网络请求。
对于给定的 URL,LocalFileURLProtocol
使用以下规则匹配文件名:
- 搜索与 URL 同名的文件(例如,http://example.com/image.png)
- 搜索与最后一个路径组件同名的文件(例如,image.png)
- 搜索最后一个路径组件不带扩展名的文件名(例如,image)
如果无法检索到匹配的文件,则执行网络调用。
要在您的应用程序中注册 LocalFileURLProtocol
,您必须在测试生命周期中尽快调用以下 API。
URLProtocol.registerClass(LocalFileURLProtocol.self)
请注意,如果您使用的是 Alamofire,则可能会导致无法正常工作。请在此 找到相关问题和配置Alamofire以处理 URLProtocol
类的链接。
使用 ViewController containment 进行 UI 测试
ViewTestCase
主要用于测试带有自动注入代表着视图测试条件的 ViewModel
的 ViewControllerModellableView
。
如果您使用的是 ViewController containment(如上面的 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 版本,您应使用 Tempura 的特定版本。使用此表来检查您需要的 Tempura 版本。
Swift 版本 | Tempura 版本 |
---|---|
Swift 5.0 | Tempura 4.0+ |
Swift 4.2 | Tempura 3.0 |
Swift 4 | Tempura 1.12 |
📬 取得联系
如果您有任何 疑问 或 反馈,我们很乐意在 [email protected]听到您的声音。
🙋♀️ 贡献
- 如果您 发现了错误,请创建一个问题;
- 如果您有 功能请求,请创建一个问题;
- 如果您 想要贡献,请提交一个拉取请求;
- 如果您 有关于改进框架或传播知识的好想法,请 联系我们;
- 如果您想 尝试将该框架用于您的项目 或编写一个演示,请发送 repo 的链接。
👩⚖️ 许可证
Tempura遵循MIT许可证。
❓ 关于
Tempura由Bending Spoons维护。我们创建了自身的技术产品,这些产品被世界各地数百万人所使用和喜爱。听起来很酷?了解我们