WWDC笔记——Objective-C中的MVVM - 图1
尽管iOS生态系统从Objective-C每天都在增长,但一些公司仍然严重依赖它。距WWDC 2020的另一波创新浪潮已经一周了,我认为从MVVM模式实施开始重新回到Objective-C将会很有趣。
快速提醒一下,MVVM模式是架构设计模式的三个主要解耦逻辑:模型-视图-ViewModel。如果您正在Swift中寻找类似的内容,那么我过去在Swift中也讨论了这个主题,并且在RxSwift中也进行了介绍
让我们深入研究代码

模型

对于此示例应用程序,我正在构建一个播放列表应用程序,列出歌曲标题,艺术家姓名和专辑封面。

  1. // Song.h
  2. @interface Song : NSObject
  3. @property (nonatomic, strong) NSString * title;
  4. @property (nonatomic, strong) NSString * artistName;
  5. @property (nonatomic, strong) NSString * albumCover;
  6. - (instancetype)initWithTitle:(NSString*)title artistName:(NSString*)artistName albumCover:(NSString*)albumCover;
  7. - (nullable NSURL*)albumCoverUrl;
  8. @end
  9. // Song.m
  10. @implementation Song
  11. - (instancetype)initWithTitle:(NSString*)title artistName:(NSString*)artistName albumCover:(NSString*)albumCover
  12. {
  13. self = [super init];
  14. if (self) {
  15. self.title = title;
  16. self.artistName = artistName;
  17. self.albumCover = albumCover;
  18. }
  19. return self;
  20. }
  21. - (nullable NSURL*)albumCoverUrl {
  22. return [NSURL URLWithString:self.albumCover];
  23. }

从那里开始,我想在每一层之间保持清晰的分隔,所以我使用面向协议的编程方法来使代码可维护和可测试。
一种组件是获取数据,而另一种组件将解析它们
由于我们Result在Objective-C中没有类型,所以我想分别将成功错误过程解耦。为此,我对回调使用了两个闭包。即使它可以使语法的可读性降低,但我还是首选委托模式。

  1. @protocol SongParserProtocol <NSObject>
  2. - (void)parseSongs:(NSData *)data withSuccess:(void (^)(NSArray<Song *> *songs))successCompletion error:(void (^)(NSError *error))errorCompletion;
  3. @end
  4. @protocol SongFetcherProtocol <NSObject>
  5. - (void)fetchSongsWithSuccess:(void (^)(NSArray<Song *> *songs))successCompletion error:(void (^)(NSError *error))errorCompletion;
  6. @end

在这里,第一个协议SongParserProtocol负责将原始数据反序列化为模型。第二种协议SongFetcherProtocol从源获取数据并将其链接到定义的解析器以获得最终结果。
由于我还没有任何API,因此实现将依赖于模拟的JSON文件。

  1. // SongFetcher.h
  2. @interface SongFetcher : NSObject<SongFetcherProtocol>
  3. - (instancetype)initWithParser:(id<SongParserProtocol>)parser;
  4. @end
  5. // SongFetcher.m
  6. @interface SongFetcher()
  7. @property (nonatomic, strong) id<SongParserProtocol> parser;
  8. @end
  9. @implementation SongFetcher
  10. - (instancetype)initWithParser:(id<SongParserProtocol>)parser
  11. {
  12. self = [super init];
  13. if (self) {
  14. self.parser = parser;
  15. }
  16. return self;
  17. }
  18. /// Mocked data based on JSON file
  19. - (void)fetchSongsWithSuccess:(void (^)(NSArray<Song *> *))successCompletion error:(void (^)(NSError *))errorCompletion {
  20. __weak SongFetcher * weakSelf = self;
  21. void (^dataResponse)(NSData *) = ^(NSData *data){
  22. [weakSelf.parser parseSongs:data withSuccess:successCompletion error:errorCompletion];
  23. };
  24. // TODO: improve error handling at each steps
  25. dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{
  26. FileReader * reader = [[FileReader alloc] init];
  27. [reader readJson:@"songs" withSuccess:dataResponse error:errorCompletion];
  28. });
  29. }

服务已准备就绪,可以在后台队列中分派工作,我们已经准备好构建ViewModel。

视图模型

由于目标是显示的表示形式Song,因此我想创建一个特定的模型来表示其在单元格中的显示。它避免了将我们的业务模型暴露给UI组件本身。它使每个属性的显示更加明确。

  1. // SongDisplay.h
  2. @class Song;
  3. @interface SongDisplay : NSObject
  4. @property (nonatomic, readonly, nullable) NSString *title;
  5. @property (nonatomic, readonly, nullable) NSString *subtitle;
  6. @property (nonatomic, readonly, nullable) UIImage *coverImage;
  7. - (instancetype)initWithSong:(nonnull Song*)song;
  8. @end

面试题持续整理更新中,如果你正在面试或者想一起进阶,不妨添加一下交流群1012951431一起交流。

转到ViewModel,我想展示一种获取此新模型的方法。它还必须包含其他方法,以稍后提供UITableViewDataSource。

  1. // ViewModel.h
  2. @interface ViewModel : NSObject
  3. - (void)getSongsWithSuccess:(void (^)(NSArray<SongDisplay*> *songs))successCompletion error:(void (^)(NSError *error))errorCompletion;
  4. - (NSUInteger)numberOfItems;
  5. - (NSUInteger)numberOfSections;
  6. - (nullable SongDisplay *)itemAtIndexPath:(NSIndexPath *)indexPath;
  7. @end

最后,我们可以实现这些方法并填补空白。重要的是将先前准备的每个协议重用到构造函数中。我可以从用于单元测试的自定义构造函数中注入它们,但暂时将其简化。

  1. // ViewModel.m
  2. @interface ViewModel()
  3. @property (nonatomic, strong) id<SongFetcherProtocol> fetcher;
  4. @property (nonatomic, strong) NSArray<SongDisplay *> *items;
  5. @end
  6. @implementation ViewModel
  7. - (instancetype)init
  8. {
  9. self = [super init];
  10. if (self) {
  11. self.items = @[];
  12. self.fetcher = [[SongFetcher alloc] initWithParser:[[SongParser alloc] init]];
  13. }
  14. return self;
  15. }
  16. - (void)getSongsWithSuccess:(void (^)(NSArray<SongDisplay *> * _Nonnull))successCompletion error:(void (^)(NSError * _Nonnull))errorCompletion {
  17. __weak ViewModel *weakSelf = self;
  18. [self.fetcher fetchSongsWithSuccess:^(NSArray<Song *> *songs) {
  19. NSMutableArray * items = [[NSMutableArray alloc] init];
  20. for (Song *song in songs) {
  21. [items addObject:[[SongDisplay alloc] initWithSong:song]];
  22. }
  23. [weakSelf setItems:items];
  24. successCompletion(items);
  25. } error:errorCompletion];
  26. }
  27. - (NSUInteger)numberOfItems {
  28. return self.items.count;
  29. }
  30. - (NSUInteger)numberOfSections {
  31. return 1;
  32. }
  33. - (SongDisplay *)itemAtIndexPath:(NSIndexPath *)indexPath {
  34. if (indexPath.row >= self.items.count) {
  35. return nil;
  36. }
  37. return self.items[indexPath.row];
  38. }
  39. @end

请注意,id<SongFetcherProtocol>在将来需要新实现的情况下,我会避免公开特定类型的对象。
最后,我们可以使用提取程序并将所有结果Song转换为,SongDisplay并始终以完成来结束。提取程序会尽力从正确的位置获取数据,然后格式化回正确的模型。
我们准备用View本身完成它。

视图

为了表示视图,我使用,UIViewController并且由于它是一个很小的应用程序,因此我还将在其中实现必要的功能UITableViewDataSource

  1. // ViewController.h
  2. @interface ViewController : UIViewController<UITableViewDataSource>
  3. @property (nonatomic, strong) IBOutlet UITableView * tableView;
  4. @end

最后,我们可以通过连接在包执行ViewControllerViewModel

  1. @interface ViewController ()
  2. @property (nonatomic, strong) ViewModel * viewModel;
  3. @end
  4. @implementation ViewController
  5. - (instancetype)initWithCoder:(NSCoder *)coder
  6. {
  7. self = [super initWithCoder:coder];
  8. if (self) {
  9. self.viewModel = [[ViewModel alloc] init];
  10. }
  11. return self;
  12. }
  13. - (void)viewDidLoad {
  14. [super viewDidLoad];
  15. [self.tableView setDataSource:self];
  16. [self getData];
  17. }
  18. - (void)getData {
  19. __weak ViewController *weakSelf = self;
  20. [self.viewModel getSongsWithSuccess:^(NSArray<SongDisplay *> * _Nonnull songs) {
  21. dispatch_async(dispatch_get_main_queue(), ^{
  22. [weakSelf.tableView reloadData];
  23. });
  24. } error:^(NSError * _Nonnull error) {
  25. // TODO handle error
  26. }];
  27. }
  28. //MARK: - UITableViewDataSource
  29. - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
  30. return self.viewModel.numberOfSections;
  31. }
  32. - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
  33. return self.viewModel.numberOfItems;
  34. }
  35. - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
  36. SongTableViewCell * cell = [tableView dequeueReusableCellWithIdentifier:@"SongTableViewCell"];
  37. if (!cell) {
  38. assert(false);
  39. }
  40. [cell setDisplay:[self.viewModel itemAtIndexPath:indexPath]];
  41. return cell;
  42. }
  43. @end

在此实现中,我触发ViewModel来获取数据并确保相应地在主线程上重新加载数据。我们可以进一步推进,并使用差异算法仅更新必要的内容,而不是重新加载所有内容。
单元是基于SongDisplay模型构建的,因此不会暴露于任何业务逻辑,UI始终与其余部分保持分离。
UI的其余部分直接通过Storyboard实现,以加快设计速度。


最后,我们有一个完整的MVVM模式实现,其中各层之间明确分开:代码易于维护和测试
像每个解决方案一样,没有万灵药,总会有一些折衷。如上所述,使用闭包而不是委派是我的选择,但是如果您觉得这种局限性,则可能希望选择一种更具可读性的方法。
我没有故意涵盖某些领域,例如加载图像封面,实现网络api或错误处理,因为这比本文的目标要远一些。
您可以在Github上名为ObjectiveCSample的项目中找到更多细节。

面试资料:

面试题持续整理更新中,如果你正在面试或者想一起进阶,不妨添加一下交流群1012951431一起交流。
面试题资料或者相关学习资料都在群文件中 进群即可下载!

WWDC笔记——Objective-C中的MVVM - 图2