DateFormatter 静态实例的一个小坑

问题 目前开发的 app 主要服务于澳洲用户,开发团队由中澳两地开发人员组成,所以写和 DateFormatter 相关测试时,通常会指定 Calendar 所处时区。否则可能出现测试在本地运行完美通过,但澳洲同事本地或者 CI 上挂掉的情况出现。 var mockCalendar = Calendar(identifier: .iso8601) let mockDate = mockCalendar.date( from: DateComponents(year: 2025, month: 3, day: 2, hour: 12) )! // 如果跑测试时候的时区是 Australia/Sydney,那么生成的日期是 02 Mar 09:00 #expect(humanizedDate(date: mockDate) == "02 Mar 12:00”) // ❌ 要解决这个问题,通过指定 Calendar 及 DateFormatter 的时区为同一时区即可。 var mockCalendar = Calendar(identifier: .iso8601) + mockCalendar.timeZone = TimeZone(identifier: "Australia/Sydney")! let formatter = DateFormatter() + formatter.dateFormat.timeZone = mockCalendar.timeZone let mockDate = mockCalendar.date( from: DateComponents(year: 2025, month: 3, day: 2, hour: 12) )! #expect(humanizedDate(date: mockDate, formatter: formatter) == "02 Mar 12:00”) // ✅ 优化 但在生产代码中考虑到 DateFormatter 在使用的时候如果不重用实例,则会额外耗费十几倍的时间。 ...

March 2, 2025

构建易维护的 Design System: 为什么 SwiftUI 会是更好的选择

该文已同步发布至 Thoughtworks Insights – What benefits does SwiftUI offer for building a design system? 原文链接 SwiftUI 自 iOS 13 发布以来,虽然已经面向公众近 4 年,但由于在实现复杂布局时的性能不佳,以及因其内置组件的底层实现变更(iOS 16 上 List 的底层实现从 UITableView 改成了 UICollectionView ),导致开发者们原本良好运行代码随系统升级被破坏了。iOS 14 之前 SwiftUI 的开发者体验也让人一言难尽。尽管有很多的槽点,但我们还是能发现社区整体上还是比较接纳 SwiftUI。所以如果你对 SwiftUI 还有所犹豫,不清楚为何要使用它,这篇文章或许能够带来一些新的想法。 本篇文章主要是想要通过 Design System 为切入点,同大家讨论相比起 UIKit,为什么更推荐使用 SwiftUI 来实现大多数业务场景下的 UI 组件。 首先简单概括一下 Design System 是什么,Design System 是一个包含了设计原则、组件库和代码资源等系统化的指导,旨在促进团队间的协作和提高项目的一致性。它可以帮助团队更快速、高效地构建应用程序,同时确保应用程序的外观和交互保持一致。 接下来就进入正题,从以下几点来探讨一下通过 SwiftUI 构建 Design System 有哪些优势。 声明式语法会更具有可读性和易于实现 内建的一致性和统一性表达 单向数据流带来的可预测性 与 Design System 有更相似的哲学思想 声明式语法会更具有可读性和易于实现 首先从实现和维护成本上来说,SwiftUI 与 Apple 现有的 UIKit 和 AppKit 不同,SwiftUI 采用了声明式语法构建 UI。由于声明式语法更关注于描述 UI 的最终效果,而不是具体实现方式。这使得通过声明式语法编写的 UI 组件更具可读性,有助于团队更好地协作实现 Design System。 ...

June 2, 2023

iOS 架构之另一种依赖注入的思路

在 iOS 业务开发过程经常面对网络请求,数据持久化这样带有副作用的操作。为了能够在测试中 mock 这些操作,通常的做法就是抽象一层 protocol 出来,然后编写不同的实现。 比如需要处理一个简陋的注册业务(示例省略了一点细节),需要用户输入信息后发送网络请求,成功后返回对应用户对象。 首先为网络请求定义一个 protocol: protocol SignUpRepositoryProtocol: RepositoryProtocol { func handleSignUp(name: String, email: String, pwd: String) -> AnyPublisher<User, Error> } 对其进行实现: struct SignUpRepository: SignUpRepositoryProtocol { func handleSignUp(name: String, email: String, pwd: String) -> AnyPublisher<User, Error> { client(.signUp(name, email, pwd)) .map .decode .eraseToAnyPublisher() } } 将其注入到 ViewModel 或是 Interactor 中(取决于你采取的架构是什么 :p ),并且调用对应方法: class SignUpViewModel { enum State { case loading case success case failed } let repository: SignUpRepositoryPortocol var state: State = .loading init(repository: SignUpRepositoryPortocol) { self.repository = repository } func onSubmit(name: String, email: String, pwd: String) { repository.hanldeSignUp(name: name, email: email, pwd: pwd) .sink {[weak self] completion in switch completion { case .failure: self.?state = .failed case .finished: break } } receiveValue: {[weak self] result in self?.state = .success }.store(in: &bag) } } 由此如果需要测试对应的方法,只需要再创建一份 MockSignUpRepository 的实现即可,比如想要测试注册成功或失败场景下的处理: struct MockSignUpRepository: SignUpRepositoryPortocol { let shouldSignUpSuccess: Bool func handleSignUp(name: String, email: String, pwd: String) -> AnyPublisher<User, Error> { if shouldSignUpSuccess { Just(User.mock) .mapError{ _ in SignUpError.someError } .eraseToAnyPublisher() } else { Fail(error: SignUpError.someError) .eraseToAnyPublisher() } } } 在编写测试的时候,传入 SignUpViewModel 的依赖替换成我们想要测试的 Mock 实现: ...

March 15, 2022

Swift 状态管理 —— 如何拆分庞大的 reducer

因为项目需要使用 SwiftUI,想起来之前买过喵神的 《SwiftUI 与 Combine 编程》 。书中介绍了 Redux 这一在 Web 前端领域广泛被验证过的数据管理模式是如何通过 Swift 来实现的,非常推荐 SwiftUI 初见者阅读。 在学习过程中还产生了一个疑问,如果 reducer 越来越大,有什么更 “swift” 的办法能解决这一问题呢?(在 Redux.js 中的原生解决方案是 combineReducers) 拆分 Reducer 首先看看问题在代码中的表现是什么样的,假设我们有这样一个 reducer: func appReducer(appState: inout AppState, action: AppAction) -> Void { switch action { case .emailValid(let isValid): appState.settings.isEmailValid = isValid case .register(let email, let password): appState.settings.loginUser = User(email, password) case .login(let email, let password): appState.settings.loginUser = User(email, password) case .logout: appState.settings.loginUser = nil case .loadPokemon(let result): appState.pokemonList.pokemons = result case .favoratePokemon(let pokemon): appState.favoritePokemons.append(pokemon) case .removeFavoritePokemon(let pokemon): let index = appState.favoritePokemons.indexOf(pokemon) appState.favoritePokemons.remove(at: index) } 应用的 action 主要包含三个模块: 账号登录注册注销 对神奇宝贝数据进行加载 处理对神奇宝贝数据的收藏和取消收藏 从这段代码我们很快就能发现,即使只是非常简单的示例也已经包含了不短的代码了。这里还省略掉了处理状态时可能还需要的异步 action 的处理(数据加载等)。这还仅仅只有两个非常简单的界面状态,当面对真实的 app 所需要处理的数十个页面状态会更恐怖。 将 reducer 拆分成如下三个独立 reducer: func accountReducer(appState: inout AppState, action: AppAction) -> Void { switch action { case .emailValid(let isValid): appState.settings.isEmailValid = isValid case .register(let email, let password): appState.settings.loginUser = User(email, password) case .login(let email, let password): appState.settings.loginUser = User(email, password) case .logout: appState.settings.loginUser = nil default: break } func pokemonListReducer(appState: inout AppState, action: AppAction) -> Void { switch action { case .loadPokemon(let result): appState.pokemonList.pokemons = result default: break } func favoritePokemonReducer(appState: inout AppState, action: AppAction) -> Void { switch action { case .favoratePokemon(let pokemon): appState.favoritePokemons.append(pokemon) case .removeFavoritePokemon(let pokemon): let index = appState.favoritePokemons.indexOf(pokemon) appState.favoritePokemons.remove(at: index) default: break } 因为对 reducer 的数量并不确定,所以这里使用可变参数来构建 combine 方法,对传入的 reducer 进行遍历调用处理 appState。 ...

January 16, 2022