最近,在 r/swift 子论坛上,我偶然发现了一篇介绍“整洁架构”项目示例的帖子。这引起了我的兴趣,于是我决定在 GitHub 上下载并仔细研究。
帖子截图
初看代码颇为复杂,让我感到迷惑。但在下载和深入研究后,我发现所有组件都整合在一起,项目实现了想要的功能。但我发现该项目的网络模块的复杂性较高。仅两个简单的网络查询操作竟涉及如此多的文件,让人难以理解,让我颇为惊讶。
因此,我决定对网络层进行重构,使其更加模块化,并对整体组合和用户界面进行了小幅优化。为此,我创建了独立的项目对原始项目代码进行重构,你可以在文末找到原始项目和我重构后的项目链接。
网络层——消除嵌套和多余类型
原项目的网络层通过协议和类型结构实现了高度模块化,每个协议和类型分别负责特定功能,大致结构如下:
NetworkManager -> RequestManager -> RequestProtocol -> DataParser -> DataSource -> Repository -> UseCase
上述每一个类型都承担了网络过程的一部分职责,例如 DataParser 负责数据解析,如果想改变数据的解析方式,可以通过替换新的 DataParser 来实现,这种组合性是一项优点。
但问题在于,由于这些类型相互嵌套,使人难以整体理解,且每个类型都存于单独的文件中。许多通过 Swinject 解析器进行注入,这使得整个网络层的工作流程变得难以追踪。正如 r/swift 中的一名评论者所言,这为代码增加了一层不必要的“中间层”。
更令人费解的是,尽管作者增加了许多协议和类型来提高代码的灵活性,但其中存在很多硬编码的默认值。例如,DataParser 被直接编码在代码中,而 RequestProtocol.request() 的创建仅通过协议本身的扩展方法来实现。这种在增加了类型和复杂性后未充分利用它们的优势的做法,实在让人觉得可惜。
为了消除冗余的嵌套以及不必要的类型和协议,我们可以引入一个全新的方法:modelFetcher。
static func modelFetcher<T, U: Codable>(
createURLRequest: @escaping (T) throws -> URLRequest,
store: NetworkStore = .urlSession
) -> (T) async -> Result<BaseResponseModel<PaginatedResponseModel<U>>, AppError> {
let networkFetcher = self.networkFetcher(store: store)
let mapper: (Data) throws -> BaseResponseModel<PaginatedResponseModel<U>> = jsonMapper()
let fetcher = self.fetcher(
createURLRequest: createURLRequest,
fetch: { request -> (Data, URLResponse) in
try await networkFetcher(request)
}, mapper: { data -> BaseResponseModel<PaginatedResponseModel<U>> in
try mapper(data)
})
return { params in
await fetcher(params)
}
}
此函数的设计旨在保持与原代码相同的组合功能,但未采用协议(protocols)和类型(types),而是通过直接注入操作行为来实现。需要说明的是,如果这样更方便,你还可以将其构造成一个带闭包的结构体,而不仅限于闭包。
接下来,实际的请求获取闭包创建过程被大大简化,唯一会变化的是请求创建部分。
static func characterFetcher(
store: NetworkStore = .urlSession
) -> (CharacterFetchData) async -> Result<BaseResponseModel<PaginatedResponseModel<CharacterModel>>, AppError> {
let createURLRequest = { (data: CharacterFetchData) -> URLRequest in
var urlParams = ["offset": "\(data.offset)", "limit": "\(APIConstants.defaultLimit)"]
if let searchKey = data.searchKey {
urlParams["nameStartsWith"] = searchKey
}
return try createRequest(
requestType: .GET,
path: "/v1/public/characters",
urlParams: urlParams
)
}
return self.modelFetcher(createURLRequest: createURLRequest)
}
优化后,我们无需深入到许多不同的文件中,也无需理解众多的协议和类型,因为我们可以通过直接注入闭包来实现相同的行为。NetworkStore 负责实际将数据发送到网络,我们将其传递到构造函数中是为了方便后续的测试模拟(如果有需要的话)。
下面的例子展示了如何通过使用行为替代类型,将原始项目中的协议和类型进行转换:
protocol NetworkManager {
func makeRequest(with requestData: RequestProtocol) async throws -> Data
}
class DefaultNetworkManager: NetworkManager {
private let urlSession: URLSession
init(urlSession: URLSession = URLSession.shared) {
self.urlSession = urlSession
}
func makeRequest(with requestData: RequestProtocol) async throws -> Data {
let (data, response) = try await urlSession.data(for: requestData.request())
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else { throw NetworkError.invalidServerResponse }
return data
}
}
这段代码还可以继续优化变得更简洁:
static func networkFetcher(
store: NetworkStore
) -> (URLRequest) async throws -> (Data, URLResponse) {
{ request in
let (data, response) = try await store.fetchData(request)
if let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode != 200 {
throw NetworkError.invalidServerResponse
}
return (data, response)
}
}
可以看出,我们在移除类型和协议的情况下实现了相同的功能。
另一个案例是通过函数创建一个 JSON 映射器,并将其作为闭包返回,保留协议的灵活性,却不依赖协议。例如:
static func jsonMapper<T: Decodable>() -> (Data) throws -> T {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return { data in
try decoder.decode(T.self, from: data)
}
}
在我看来,与基于协议/类型的方法相比,这种组合方式让网络层的实现变得更为直观和简洁。
这并不意味着你不应使用协议,但在选择使用协议和类型时,应明确了解其用途,并思考是否真的需要为每 2-3 行代码创建一个完整的类型。
项目模块划分
总体上,应用程序的模块划分还算理想。然而,我觉得可以进一步完善项目,方法是对网络模块进行明确的划分。让我们思考一下:应用程序真的需要了解它将使用哪个 JSON 映射器作为网络特性吗?我们是否可以更改网络特性的JSON映射器而不破坏整个结构?如果网络模块能够自主处理这些内容,那就更好了,这样我们可以专注于使用它的主要目的:获取超级英雄数据。
我们应该限制网络模块接收的内容,仅限于有意识地改变的部分,如用于测试的输入,而不过多暴露。此外,我们可以只公开实际使用的部分,例如fetcher功能,而不是整个NetworkStore模块的所有底层特性,并将其设为public。
值得注意的是,网络模块不应涉及域的内容,最好将ArkanaKeys依赖从整个项目中独立出来,单独置于网络模块中。拥有一个完全隔离的网络模块,可以让我们在制作任何关于漫威超级英雄的应用时,轻松地复用所有的网络逻辑。
在提供的示例代码中,我仅进行了“虚拟模块化”操作,没有为网络模块创建独立的框架,也没有将ArkanaKeys的依赖关系转移到那里。相反,我创建了一个文件夹并加入了访问控制,模拟了完全独立框架的情形。这样做是为了使演示项目简洁,实际上,你只需创建一个框架并添加到项目中即可。
另一个更远大的目标是将 UI 和演示逻辑进行分离。目前这两者相当耦合,我觉得这并不是问题。我删除了 Presentation 文件夹,并把它们和 UI 层放在一起,因为在这一点上,很难想象使用 HomeViewModel来做除了 HomeView以外的事情,但这是一个组织代码的个人喜好问题。
我最终使用了一个简单的 Container类来代替 Swinject,但这也是个人喜好的问题。无论如何,解析器/容器应该避免尝试解析太多具体的网络类型,比如 NetworkManager, DataSource, Repositories和UseCases。在这种情况下,让我们注入 NetworkStore(我用来替换 NetworkManager的类型)并直接解析UseCase 的依赖。
UI 层的优化更新
以下是关于 UI 层的一些优化更新,通过减少缩进和删除 AnyView类型来提高可读性和性能。将 View从 body中提取出来以提高可读性,在我看来,尽可能减少缩进到只有几个级别是有帮助的。原始应用程序在 HomeView中达到了 13 个缩进级别!而且,它是应用程序的根视图,所以从一开始就尽可能地使其可读是一个好主意。通过将 homeView提取为一个计算属性,我们可以很容易地将缩进减少到只有五个级别。
示例如下:
public var body: some View {
NavigationStack {
ZStack {
BaseStateView(
viewModel: viewModel,
successView: homeView,
emptyView: BaseStateDefaultEmptyView(),
createErrorView: { errorMessage in
BaseStateDefaultErrorView(errorMessage: errorMessage)
},
loadingView: BaseStateDefaultLoadingView()
)
}
}
.task {
await viewModel.loadCharacters()
}
}
我想最后提一下的是,这个应用使用了一个 BaseStateView,它接受四个不同的 AnyView来表示应用的不同状态,比如成功、空、错误等。BaseStateView使用泛型来代替 AnyView会更合适,因为 AnyView对于 SwiftUI 来说并不总是性能很好。这样会提高性能,但是一个缺点是,它让我们必须传入我们想要的具体的 View,比如成功/空/创建/加载,而不是让它们在构造函数中自动为我们完成。
示例如下:
struct BaseStateView<S: View, EM: View, ER: View, L: View>: View {
@ObservedObject var viewModel: ViewModel
let successView: S
let emptyView: EM?
let createErrorView: (_ errorMessage: String?) -> ER?
let loadingView: L?
...
}
为了提高可读性,你可以使用如SuccessView、EmptyView等名称。
在 SwiftUI 的上下文中,使用单一基础控制器/视图的方法可能不太符合习惯。与直接将所有这些状态处理器添加到基础视图上相比,以 ViewModifiers的形式将它们组合起来并添加感觉更为自然。不过,每种方法都有其优劣之处。如果你想强调构造函数的使用,并且想通过减少 ZStacks 的使用来实现,那么这种方法也是可取的。
struct ErrorStateViewModifier<ErrorView: View>: ViewModifier {
@ObservedObject var viewModel: ViewModel
let errorView: (String) -> ErrorView
func body(content: Content) -> some View {
ZStack {
content
if case .error(let message) = viewModel.state {
errorView(message)
}
}
}
}
结论
衷心感谢 mohaned_y98 提供的启发和出色的示例项目!本文基于清晰的架构原则,采用了与原始项目不同的风格进行探索。相较于我所重构的项目,原始项目有其独特的优势,你可根据项目需求选择适合的设计方案。
在尽量保留初衷的同时,我对项目进行了重构,增强了其人体工程学和可读性。鉴于用户界面或展示层已经构建得非常稳固,我未在这些方面投入过多精力。如果从头开始,我可能会选择不同的编码方式,但现有的代码编写得恰到好处,且运作正常。
原始项目和我重构后的项目链接放在下方,欢迎下载阅读我重构后的项目。你认为我忽略了哪些方面?你会有哪些不同的实现方法?
原始项目: https://github.com/Mohanedy98/swifty-marvel我重构后的项目:https://github.com/terranisaur/Demo-SwiftyMarvelous
译者介绍
刘汪洋,51CTO社区编辑,昵称:明明如月,一个拥有 5 年开发经验的某大厂高级 Java 工程师,拥有多个主流技术博客平台博客专家称号。
原文标题:Clean Code Review: Removing All the Extra Types,作者:Alex Thurston