iOS应用程序中的架构方法

今天,我们将讨论iOS开发中的架构方法,某些事物的实现方面的细微差别和开发。 我将告诉您我们遵循的方法,并进一步详细介绍。


立即显示所有卡片。 我们使用MVVM-R(MVVM +路由器)。


实际上,这是常规的MVVM,其中屏幕之间的导航位于服务中的单独层(路由器和接收数据的逻辑)中。 接下来,我们将考虑在每个层的实现中所取得的成就。


为什么选择MVVM,而不是VIPER或MVC?


与MVC不同,MVVM在各层之间负有相当大的责任。 尽管屏幕的ViewModel也由协议关闭,但它没有VIPER中的“服务”代码那么多。 这种体系结构有点类似于VIPER,ViewModel中仅合并了Presenter和Interactor,并且通过使用反应式编程和活页夹(我们使用ReactiveSwift)简化了层之间的连接。


实体


我们使用两层数据模型:第一层与数据库绑定(以下称为托管对象 ),第二层是所谓的普通对象 ,它们与数据库无关。


每个简单实体都实现可翻译协议,该协议可从托管对象初始化并从中创建托管对象。 我们使用Realm作为数据库,在本例中RealmSwift.ObjectRealmSwift.Object 。 映射通过Codable :将它们映射为纯对象并保存为托管对象。 进一步的服务和ViewModel仅适用于纯对象。


 protocol Translatable { associatedtype ManagedObject: Object init(object: ManagedObject) func toManagedObject() -> ManagedObject } 

为了从数据库中保存,检索和删除对象,使用了一个单独的实体-存储。 由于协议已关闭存储,因此我们不依赖于特定数据库的实现,并且在必要时可以用CoreData替换Realm。


 protocol StorageProtocol { func cachedObjects<T: Translatable>() -> [T] func object<T: Translatable>(byPrimaryKey key: AnyHashable) -> T? func save<T: Translatable>(objects: [T]) throws func save<T: Translatable>(object: T) throws func delete<T: Translatable>(objects: [T]) throws func delete<T: Translatable>(object: T) throws func deleteAll<T: Translatable>(ofType type: T.Type) throws } 

这种方法的优缺点是什么?


每个数据库都有其自己的特征。 例如,已经存储在数据库中的Realm对象只能在创建它的流的框架内使用。 这很不方便。


同样,当对象位于RAM中时,可以从数据库中删除该对象,并且访问该对象将崩溃。 核心数据具有相同的功能。 因此,我们从数据库中获取对象,将它们转换为普通对象,然后使用它们。


使用这种方法,代码变得更大,需要得到支持。 无论数据库的功能如何,我们都会失去使用出色芯片的能力。 对于CoreData,这是一个FetchedResultsController,我们可以在其中控制实体数组中的所有插入,删除和更改。 与Realm中的机制相同。


核心组成


核心组件是执行其任务之一的实体。 例如,映射,与数据库的交互,发送和处理网络请求。 上一段中的存储只是核心组件之一。


通讯协定


我们积极使用协议。 协议封闭了所有核心组件,并且可以对单元测试进行模拟或测试实现。 这样,我们获得了一定的实现灵活性。 所有依赖项都传递给init。 初始化每个对象时,我们了解它们之间存在什么样的依赖关系,以及它在内部使用的依赖关系。


HTTP客户端


网络请求由NetworkRequestParams协议描述。


 protocol NetworkRequestParams { var path: String { get } var method: HTTPMethod { get } var parameters: Parameters { get } var encoding: ParameterEncoding { get } var headers: [String: String]? { get } var defaultHeaders: [String: String]? { get } } 

我们使用enum来描述网络请求。 看起来像这样:


 enum UserNetworkRouter: URLRequestConvertible { case info case update(userJson:[String : Any]) } extension UserNetworkRouter: NetworkRequestParams { var path: String { switch self { case .info: return "/users/profile" case .update: return "/users/update_profile" } } var method: HTTPMethod { switch self { case .info: return .get case .update: return .post } } var encoding: ParameterEncoding { switch self { case .info: return URLEncoding() case .update: return JSONEncoding() } } var parameters: Parameters { switch self { case .info: return [:] case .update(let userJson): return userJson } } } 

每个NetworkRouter实现URLRequestConvertible协议。 我们将其提供给网络客户端,网络客户端将其转换为URLRequest并将其用于预期目的。


网络客户端如下:


 protocol HTTPClientProtocol { func load(request: NetworkRequestParams & URLRequestConvertible) -> SignalProducer<Data, Error> } 

映射器


我们使用Codable进行数据映射。


 protocol MapperProtocol { func map<MappingResult: Codable>(data: Data, dateDecodingStrategy: JSONDecoder.DateDecodingStrategy) -> SignalProducer<MappingResult, Error> } 

推送通知


每个推送通知都有一个类型,每种类型都有自己的处理程序。 处理程序从通知中接收带有信息的字典。 聚合实体持有处理程序;由她来接收推送并将其定向到所需的处理程序。 这是一种相当可扩展的方法,如果您需要以不同方式处理几种类型的推送通知,则使用起来很方便。


服务项目


粗略地说,一项服务负责一个实体。 考虑一个社交网络应用程序的示例。 有一个用户的服务器接收用户-他本人,并且如果我们对其进行了编辑,则给出更改的实体。 有一个邮政服务,可以接收邮件列表,详细的邮件,付款服务等。 等


所有服务都包含核心组件。 当我们在服务上调用方法时,它开始处理核心组件的各种方法,并最终给出结果。


通常,服务确实适用于特定的屏幕,或者适用于屏幕视图模型(有关更多信息,请参见下文)。 如果在离开屏幕时该服务没有被破坏,但继续满足了已经不必要的网络请求,并且将减慢其他请求的速度。 这可以手动控制,但是维护这样的系统将更加困难。 但是,这种方法有一个缺点:如果即使在我们退出屏幕后仍需要服务结果,则您将不得不寻找其他解决方案,也许会使某些服务成为单例。


服务是无状态的。 由于服务不是单调的,因此我们可以具有同一服务的多个实例,其中状态可以彼此不同。 这可能会导致错误的行为。


一种服务的方法示例:


 func currentUser() -> SignalProducer<User, Error> { let request = UserNetworkRouter.info return httpClient.load(request: request) .flatMap(.latest, mapUser) .flatMap(.latest, save) } 

视图模型


我们将ViewModel分为2种类型:


  • 屏幕的ViewModel(ViewController)
  • UIView的ViewModel(包括表单元格或UICollectionView)

ViewController的ViewModel负责屏幕的逻辑。 通常,这是发送网络请求,准备数据,响应UI事件。


ViewModel为来自服务的视图准备所有数据。 如果到达实体列表,则ViewModel会将其转换为ViewModel列表,并将其绑定到视图。 如果存在状态(有一个选中标记/没有选中标记),则也将对其进行管理并将其传递给ViewModel。


ViewModel还控制导航逻辑。 有一个单独的Router层用于导航,但是ViewModel提供了命令。


视图模型的典型功能:吸引用户,联系用户服务,根据接收到的值创建ViewModel。 加载完所有内容后,View将使用ViewModel并绘制视图单元格。


由于与服务相同的原因,协议关闭了屏幕的ViewModel。 但是,还有另一种有趣的情况:例如,一个银行应用程序,其中每个操作(转移资金,开设帐户,冻结帐户)都由SMS确认。 在确认屏幕上,有一个代码输入字段和一个“再次发送”按钮。


ViewModel通过以下协议关闭:


 protocol CodeInputViewModelProtocol { ///    func send(code: String) -> SignalProducer<Void, Error> ///    func resendCode() -> SignalProducer<Void, Error> } 

在ViewController中,它以以下形式存储:


 var viewModel: CodeInputViewModelProtocol? 

根据我们试图通过SMS进行确认的方式,可以通过完全不同的请求来表示发送代码和发送SMS的过程,并且在确认之后,需要转换到不同的屏幕等。 由于ViewController并不关心ViewModel实际具有的时间,我们可以针对不同情况使用几种ViewModel实现,并且UI会很常见。


用于View和单元格的ViewModel通常处理数据格式和用户输入处理。 例如,存储选定/未选定状态。


 final class FeedCellViewModel { let url: URL? let title: String let subtitle: String init(feed: FeedItem) { url = URL(string: feed.imageUrl) title = feed.title subtitle = DateFormatter.feed.string(from feed.publishDate) } } 


屏幕之间的转换由路由器执行。


 class BaseRouter { init(sourceViewController: UIViewController) { self.sourceViewController = sourceViewController } weak var sourceViewController: UIViewController? } 

每个屏幕都有自己的路由器,该路由器是从基础继承的。 它具有特定屏幕的转换方法。


 final class FeedRouter : BaseRouter { func showDetail(viewModel: FeedDetailViewModelProtocol) { let vc = FeedDetailViewController() vc.viewModel = viewModel sourceViewController?.navigationController?.pushViewController(vc, animated: true) } } 

从上面的示例中可以看到,“模块”的组装在路由器中进行。 这在形式上与SOLID中的字母S相矛盾,但实际上它非常方便且不会引起问题。


有时在不同的路由器中需要使用相同的方法。 为了避免多次编写,我们创建了一个协议,其中包含一些通用方法,并且将对其进行extension 。 现在,足以为该协议签名所需的路由器,并且它将具有必要的方法。


 protocol FeedRouterProtocol { func showDetail(viewModel: FeedDetailViewModelProtocol) } extension FeedRouterProtocol where Self: BaseRouter { func showDetail(viewModel: FeedDetailViewModelProtocol) { let vc = FeedDetailViewController() vc.viewModel = viewModel sourceViewController?.navigationController?.pushViewController(vc, animated: true) } } 

检视


传统上,视图负责向用户显示信息并处理用户操作。 在MVVM中,我们认为ViewController是一个View。 重要的是,ViewModel中不应该包含任何复杂的逻辑。 无论如何,即使在MVC中,也很难重载ViewController,尽管这样做很难。


查看命令ViewModel。 如果加载了ViewController,则提供ViewModel命令:从网络或从缓存加载数据。 View还接受来自ViewModel的信号。 如果ViewModel说发生了某些变化(例如,数据已经加载),则View会对它做出反应并重新绘制。


我们不使用情节提要。 导航与ViewController紧密相关,很难融入到架构中。 情节提要中经常会发生冲突,这是一个单独的“乐趣”,需要编辑。


接下来要做什么?


您可以为模型使用代码生成(可翻译),因为现在已手动注册了从数据库对象到计划对象的所有初始化过程,反之亦然。


您还可以使用更通用的查询方案,因为许多服务方法如下所示:转到网络,应用映射,保存到数据库。 也可以将其通用化以设置通用骨架。


我们已经考虑了架构方法,但不要忘记,高质量的应用程序不仅是架构,而且是平滑,响应迅速,方便的界面。 爱您的用户并编写优质的应用程序。

Source: https://habr.com/ru/post/zh-CN458590/


All Articles