
本课目标第1课解决了并发安全。本课解决另一个进阶问题如何让 ViewModel 不直接依赖网络或数据库从而方便预览、替换和测试。掌握协议抽象、Repository 模式、自定义 EnvironmentKey、环境注入、Mock 替身并用 Swift Testing 写单元测试。完成后你的 SwiftUI 代码会从“能跑”变成“能换、能测、能扩展”。一、核心概念· 依赖倒置高层模块不直接依赖低层实现而是依赖协议。· BookRepository抽象数据来源界面和 ViewModel 只认识它。· LiveBookRepository真实实现可访问网络或 SwiftData。· MockBookRepository测试替身返回固定数据或模拟错误。· EnvironmentKey自定义环境键定义默认值。· EnvironmentValues扩展环境值让 Environment 能读取。· .environment(.bookRepository, …)在 App 入口或预览中注入实现。· Environment(.bookRepository)在视图中读取依赖。· MainActor Observable ViewModel接收 Repository管理界面状态。· Sendable协议和模型跨并发域安全传递。· Swift Testing用 Test、#expect 写单元测试。二、模型与协议先定接口importFoundation// 书籍模型值类型遵循 Sendable方便跨并发域传递structBook:Identifiable,Hashable,Sendable{letid:UUIDvartitle:Stringvarauthor:Stringinit(id:UUIDUUID(),title:String,author:String){self.ididself.titletitleself.authorauthor}}// Repository 协议界面和 ViewModel 只依赖这个抽象// 继承 Sendable保证实现可安全跨并发域使用protocolBookRepository:Sendable{funcfetchBooks()asyncthrows-[Book]// 读取funcadd(_book:Book)asyncthrows// 新增funcdelete(_book:Book)asyncthrows// 删除}解读ViewModel 不关心数据来自网络、数据库还是内存只关心 BookRepository 能提供什么。三、真实实现与测试替身importFoundation// 真实实现actor 保护内部状态模拟本地持久化actorLiveBookRepository:BookRepository{privatevarbooks:[Book][Book(title:SwiftUI 入门,author:小明),Book(title:布局三剑客,author:小红)]funcfetchBooks()asyncthrows-[Book]{tryawaitTask.sleep(for:.milliseconds(300))// 模拟耗时returnbooks}funcadd(_book:Book)asyncthrows{books.append(book)}funcdelete(_book:Book)asyncthrows{books.removeAll{$0.idbook.id}}}// Mock 实现测试和预览用可返回固定数据或模拟错误actorMockBookRepository:BookRepository{privatevarbooks:[Book]privatevarshouldFail:Boolinit(books:[Book][],shouldFail:Boolfalse){self.booksbooksself.shouldFailshouldFail}funcfetchBooks()asyncthrows-[Book]{ifshouldFail{throwURLError(.notConnectedToInternet)// 模拟网络错误}returnbooks}funcadd(_book:Book)asyncthrows{books.append(book)}funcdelete(_book:Book)asyncthrows{books.removeAll{$0.idbook.id}}}解读LiveBookRepository 和 MockBookRepository 都遵循同一个协议调用方不需要改代码就能切换。四、自定义 EnvironmentKey把依赖注入环境importSwiftUI// 1. 定义环境键提供默认值privatestructBookRepositoryKey:EnvironmentKey{staticletdefaultValue:anyBookRepositoryLiveBookRepository()}// 2. 扩展 EnvironmentValues让 Environment 能读写extensionEnvironmentValues{varbookRepository:anyBookRepository{get{self[BookRepositoryKey.self]}set{self[BookRepositoryKey.self]newValue}}}解读defaultValue 让未注入时也能工作实际项目中常在 App 入口注入真实实现在预览和测试中注入 Mock。五、ViewModel只依赖协议importSwiftUIimportObservation// MainActorUI 状态安全更新// Observable属性变化自动刷新界面MainActorObservablefinalclassBookListViewModel{privateletrepository:anyBookRepository// 只持有协议varbooks:[Book][]varisLoadingfalsevarerrorMessage:String?init(repository:anyBookRepository){self.repositoryrepository}// 加载书籍funcload()async{isLoadingtruedefer{isLoadingfalse}do{bookstryawaitrepository.fetchBooks()errorMessagenil}catch{errorMessage加载失败\(error.localizedDescription)}}// 添加书籍funcadd(_book:Book)async{do{tryawaitrepository.add(book)awaitload()// 重新加载保持界面一致}catch{errorMessage添加失败\(error.localizedDescription)}}// 删除书籍funcdelete(_book:Book)async{do{tryawaitrepository.delete(book)awaitload()}catch{errorMessage删除失败\(error.localizedDescription)}}}解读ViewModel 不知道数据从哪来也不直接创建 LiveBookRepository依赖由外部传入。六、视图从环境取依赖并创建 ViewModelimportSwiftUIstructBookListView:View{// 从环境读取依赖Environment(\.bookRepository)privatevarrepository// ViewModel 需要环境依赖所以先留空在 task 中创建StateprivatevarviewModel:BookListViewModel?StateprivatevarnewTitlevarbody:someView{NavigationStack{Group{ifletviewModel{List{ForEach(viewModel.books){bookinVStack(alignment:.leading){Text(book.title).font(.headline)Text(book.author).foregroundStyle(.secondary)}}.onDelete{offsetsinTask{forindexinoffsets{awaitviewModel.delete(viewModel.books[index])}}}}.overlay{ifviewModel.isLoading{ProgressView()}}.alert(提示,isPresented:.constant(viewModel.errorMessage!nil)){Button(确定){viewModel.errorMessagenil}}message:{Text(viewModel.errorMessage??)}}else{ProgressView()}}.navigationTitle(书库).toolbar{Button(添加){letbookBook(title:新书\(Int.random(in:1...100)),author:匿名)Task{awaitviewModel?.add(book)}}}.task{// 环境依赖可用后创建 ViewModelifviewModelnil{viewModelBookListViewModel(repository:repository)}awaitviewModel?.load()}}}}解读视图只从环境拿 BookRepository再交给 ViewModel换实现不需要改视图。七、App 入口与预览注入importSwiftUImainstructMyApp:App{varbody:someScene{WindowGroup{BookListView()// 注入真实实现.environment(\.bookRepository,LiveBookRepository())}}}#Preview(正常数据){BookListView().environment(\.bookRepository,MockBookRepository(books:[Book(title:预览书 A,author:作者 A),Book(title:预览书 B,author:作者 B)]))}#Preview(加载失败){BookListView().environment(\.bookRepository,MockBookRepository(shouldFail:true))}解读预览时注入 Mock不需要网络就能看到不同状态包括失败态。八、Swift Testing 单元测试importTestingimportFoundationMainActorstructBookListViewModelTests{Testfunc加载成功时更新书籍列表()async{letmockMockBookRepository(books:[Book(title:测试书,author:测试作者)])letviewModelBookListViewModel(repository:mock)awaitviewModel.load()#expect(viewModel.books.count1)#expect(viewModel.books.first?.title测试书)#expect(viewModel.errorMessagenil)}Testfunc加载失败时设置错误信息()async{letmockMockBookRepository(shouldFail:true)letviewModelBookListViewModel(repository:mock)awaitviewModel.load()#expect(viewModel.books.isEmpty)#expect(viewModel.errorMessage!nil)}Testfunc添加后重新加载()async{letmockMockBookRepository()letviewModelBookListViewModel(repository:mock)awaitviewModel.add(Book(title:新书,author:作者))#expect(viewModel.books.count1)}}解读有了协议抽象测试不需要真实网络和数据库运行快且稳定。九、习题、答案与解读选择题题1题目依赖倒置的核心是什么A. 高层依赖抽象不依赖具体实现 B. 所有代码写在一个文件 C. 不使用协议 D. 直接使用单例要求选出正确选项。答案A解读ViewModel 依赖 BookRepository 协议而不是 LiveBookRepository。题2题目自定义环境键需要实现哪个协议A. EnvironmentKey B. View C. Observable D. Codable要求选出正确选项。答案A解读EnvironmentKey 定义默认值再通过 EnvironmentValues 暴露。题3题目让 Environment 能读取自定义依赖需要扩展A. EnvironmentValues B. View C. Observable D. Sendable要求选出正确选项。答案A解读extension EnvironmentValues { var bookRepository: … }。题4题目测试时替换真实实现通常使用A. Mock 实现 B. 真实网络 C. 真实数据库 D. 单例要求选出正确选项。答案A解读Mock 返回固定数据或模拟错误测试更稳定。题5题目ViewModel 应该在哪个 actor 上更新 UI 状态A. MainActor B. 任意 actor C. Task.detached D. 后台线程要求选出正确选项。答案A解读UI 状态更新必须在主 actor。判断题题1题目ViewModel 直接创建 LiveBookRepository 是好的做法。要求判断对错。答案错解读直接创建会让替换和测试变难应该由外部注入。题2题目协议继承 Sendable 后实现类也需要满足 Sendable。要求判断对错。答案对解读actor 天然满足 Sendable值类型需所有属性可发送。题3题目EnvironmentKey 的 defaultValue 让未注入时也能工作。要求判断对错。答案对解读默认值保证环境缺失时不会崩溃。题4题目预览中也可以注入 Mock 依赖。要求判断对错。答案对解读.environment(.bookRepository, MockBookRepository(…))。题5题目单元测试必须访问真实网络。要求判断对错。答案错解读用 Mock 替身测试快速且不依赖外部环境。填空题题1题目自定义环境键遵循 ______ 协议。要求填入空白处。答案EnvironmentKey解读struct BookRepositoryKey: EnvironmentKey。题2题目扩展环境值使用 extension ______。要求填入空白处。答案EnvironmentValues解读在其中添加计算属性。题3题目注入依赖使用 .______(.bookRepository, …)。要求填入空白处。答案environment解读.environment(.bookRepository, LiveBookRepository())。题4题目读取依赖使用 ______(.bookRepository)。要求填入空白处。答案Environment解读Environment(.bookRepository) private var repository。题5题目测试断言使用 Swift Testing 的 #______。要求填入空白处。答案expect解读#expect(viewModel.books.count 1)。改错题题1题目MainActorObservablefinalclassBookListViewModel{privateletrepositoryLiveBookRepository()}要求改为可注入如何修改答案MainActorObservablefinalclassBookListViewModel{privateletrepository:anyBookRepositoryinit(repository:anyBookRepository){self.repositoryrepository}}解读依赖由外部传入才能在测试和预览中替换。题2题目extensionEnvironmentValues{varbookRepository:BookRepository}要求找出错误并改正。答案extensionEnvironmentValues{varbookRepository:anyBookRepository{get{self[BookRepositoryKey.self]}set{self[BookRepositoryKey.self]newValue}}}解读环境值需要 get/set并使用 any 表示存在类型。题3题目structMockBookRepository:BookRepository{varbooks:[Book][]funcadd(_book:Book)asyncthrows{books.append(book)}}要求找出错误并改正。答案actorMockBookRepository:BookRepository{privatevarbooks:[Book][]funcadd(_book:Book)asyncthrows{books.append(book)}}解读结构体方法不能修改自身除非 mutating但协议方法不是 mutating用 actor 或 final class 更合适。代码预测题题目letmockMockBookRepository(books:[Book(title:A,author:B)])letviewModelBookListViewModel(repository:mock)awaitviewModel.load()要求viewModel.books 是什么答案包含一本标题为“A”的书。解读Mock 返回初始化时传入的书籍。编程题题1题目为“待办”定义 TodoRepository 协议。要求包含读取、添加、删除三个方法均 async throws。参考代码structTodo:Identifiable,Hashable,Sendable{letid:UUIDvartitle:StringvarisDone:Boolinit(id:UUIDUUID(),title:String,isDone:Boolfalse){self.ididself.titletitleself.isDoneisDone}}protocolTodoRepository:Sendable{funcfetchTodos()asyncthrows-[Todo]funcadd(_todo:Todo)asyncthrowsfuncdelete(_todo:Todo)asyncthrows}解读协议只定义能力不关心实现。题2题目为 TodoRepository 写一个 Mock 实现。要求支持返回固定数据和模拟失败。参考代码actorMockTodoRepository:TodoRepository{privatevartodos:[Todo]privatevarshouldFail:Boolinit(todos:[Todo][],shouldFail:Boolfalse){self.todostodosself.shouldFailshouldFail}funcfetchTodos()asyncthrows-[Todo]{ifshouldFail{throwURLError(.timedOut)}returntodos}funcadd(_todo:Todo)asyncthrows{todos.append(todo)}funcdelete(_todo:Todo)asyncthrows{todos.removeAll{$0.idtodo.id}}}解读Mock 用 actor 保证内部状态安全。题3题目挑战——用 EnvironmentKey 注入 TodoRepository。要求定义键、扩展环境值并在预览中注入 Mock。参考代码privatestructTodoRepositoryKey:EnvironmentKey{staticletdefaultValue:anyTodoRepositoryMockTodoRepository()}extensionEnvironmentValues{vartodoRepository:anyTodoRepository{get{self[TodoRepositoryKey.self]}set{self[TodoRepositoryKey.self]newValue}}}#Preview{TodoListView().environment(\.todoRepository,MockTodoRepository(todos:[Todo(title:写测试)]))}解读环境注入让预览和测试都能轻松替换依赖。开放思考题题1题目为什么 ViewModel 不应该直接使用单例要求简要回答。参考单例让依赖隐藏、难以替换、测试互相影响。解读显式注入依赖代码更可控、更可测。题2题目EnvironmentKey 和直接传参有什么区别要求简要回答。参考环境适合跨多层共享的依赖直接传参适合局部、明确的依赖。解读全局依赖用环境局部依赖用初始化参数。题3题目Mock 和真实实现应该保持什么关系要求简要回答。参考遵循同一协议行为尽量一致但 Mock 可控制返回和错误。解读协议是契约Mock 和真实实现都要满足契约。十、全课知识点总结· 依赖倒置高层依赖协议不依赖具体实现。· BookRepository抽象数据来源便于替换和测试。· LiveBookRepository真实实现可用 actor 保护状态。· MockBookRepository测试替身返回固定数据或模拟错误。· EnvironmentKey定义自定义环境键和默认值。· extension EnvironmentValues让 Environment 能读取依赖。· .environment(.bookRepository, …)在 App 入口或预览中注入实现。· Environment(.bookRepository)在视图中读取依赖。· MainActor Observable ViewModel接收 Repository管理 UI 状态。· Sendable协议和模型跨并发域安全传递。· Swift TestingTest、#expect用 Mock 写快速稳定的单元测试。· 预览注入不同 Mock 展示正常、空、失败等状态。· 架构收益可替换、可测试、可预览、可扩展。本课达标标准能把数据依赖抽象成协议能写真实实现和 Mock 实现能定义 EnvironmentKey 并注入环境能让 MainActor Observable ViewModel 只依赖协议能在预览和 Swift Testing 中替换依赖并完成书库或待办的可测试重构。