Mô hình MVC với Objective-C trên iOS đã tồn tại và được sử dụng trong rất nhiều dự án iOS trong suốt hơn 40 năm qua. Đó cũng là mô hình phổ biến nhất mà tôi đã thấy trong các dự án của các đội và công ty khác nhau. Mặc dù có nhiều mô hình mới cố gắng thay thế MVC để giải quyết các vấn đề lớn, nhưng một số trong số đó không nổi tiếng và không được sử dụng nhiều. Bài viết này sẽ không đề cập đến tất cả các khác biệt hoặc ưu và nhược điểm của chúng, mục đích chính ở đây là sửa một số thiết kế sai lầm tôi đã thấy trước đây và giải quyết vấn đề lớn trong một số khía cạnh.
Tổng quan về MVC
MVC là một mô hình mơ hồ mà mỗi nền tảng có một cách giải thích riêng của nó. Trên nền tảng Apple/iOS, chúng ta tập trung chỉ vào mô hình này. Hình sau đây được trích từ tài liệu của Apple:
Model: Đối tượng Model bao gồm dữ liệu cụ thể cho một ứng dụng đồng thời xác định các logic và phép tính để thay đổi và xử lý dữ liệu đó.
View: Đối tượng View là một đối tượng trong một ứng dụng mà người dùng có thể nhìn thấy.
Controller: Đối tượng Controller hoạt động như một trung gian giữa một hoặc nhiều đối tượng View và một hoặc nhiều đối tượng Model trong một ứng dụng.
Chúng ta có thể thấy rằng không có sự giao tiếp nào giữa các đối tượng view và model, nhiệm vụ cập nhật giao diện của view thuộc về đối tượng controller. Điều này cũng có vẻ bình thường vì controller (thường là UIViewController) giữ tất cả các đối tượng view trực tiếp trong nó.
Chúng ta cũng có thể kiểm tra một biểu đồ khác từ Wikipedia, trong đó model cập nhật trực tiếp các thành phần của view, điều này cũng chính xác như vậy trong lập trình web ASP.Net. View được xây dựng dựa trên các tệp XAML và có thể truy cập các model, controller không giữ trực tiếp các thành phần của view.
Vậy làm thế nào để giải thích trách nhiệm của thành phần model trong MVC? Hãy tìm một tài liệu khác nói bởi Microsoft:
Model trong ứng dụng MVC đại diện cho trạng thái của ứng dụng và bất kỳ logic kinh doanh hoặc hoạt động nào mà nó phải thực hiện. Logic kinh doanh nên được đóng gói trong model, cùng với bất kỳ logic triển khai nào để lưu trạng thái của ứng dụng. Các views kiểu strongly-typed thường sử dụng các ViewModel được thiết kế để chứa dữ liệu hiển thị trên view đó. Controller tạo ra và điền giữ liệu ViewModel này từ model.
ViewModel
Đây đó! ViewModel được tách riêng khỏi định nghĩa model, nó được thiết kế cho thành phần view để giao tiếp với controller và khác biệt với các model kinh doanh, chúng ta gọi nó là ViewModel thay vì model thông thường.
Thực tế, Apple cũng giới thiệu khái niệm tương tự về MVC của họ:
Lý tưởng nhất, đối tượng model không nên có kết nối rõ ràng với đối tượng view hiển thị dữ liệu của nó và cho phép người dùng chỉnh sửa dữ liệu đó - nó không phải quan tâm về giao diện người dùng và các vấn đề liên quan đến hiển thị.
Một model không nên được sử dụng trong việc triển khai view, controller không nên cập nhật dữ liệu view với các phần dữ liệu cơ bản trong triển khai riêng của nó. Phải có một đối tượng khác để xử lý việc này, đó chính là ViewModel!
Hãy xem nhiệm vụ của thành phần ViewModel này và vai trò quan trọng của nó trong phát triển iOS của chúng ta.
Không có ViewModel
Nếu chúng ta không giới thiệu ViewModel trong dự án iOS của chúng ta, làm thế nào chúng ta có thể triển khai nó thay vào đó?
- Sử dụng Model thay thế.
Trong một triển khai UIController, nó yêu cầu dữ liệu thông qua mạng hoặc cơ sở dữ liệu và giải mã nó thành các đối tượng model, sau đó chuyển chúng trực tiếp cho các đối tượng view. Đây chính là giải pháp mà tôi đã tìm thấy trong nhiều dự án iOS trước đây. Dưới đây là mối quan hệ:
- Controller giữ các đối tượng model dữ liệu kinh doanh và view.
- View giữ các đối tượng model dữ liệu kinh doanh và thông báo sự thay đổi tương tác của người dùng thông qua các model.
- View yêu cầu thông qua mạng hoặc cơ sở dữ liệu để có được cái gì đó mới trực tiếp, đôi khi mà không cần tương tác thông qua controller.
- Model có thể cần mở rộng các thuộc tính cho trạng thái của các đối tượng giao diện người dùng.
Từ điểm này, controller hoàn toàn mất đi trách nhiệm ban đầu của nó, lớp model đã thêm một số quản lý trạng thái của lớp view cụ thể, lớp view không thể tái sử dụng được mà không có một mô hình dữ liệu khác, mô hình MVC chính thức bị phá vỡ. Điều này thật tồi tệ!
- Quản lý bởi controller.
Controller nhằm giải quyết nhiệm vụ này như mong đợi của mô hình, vì vậy việc cập nhật các view bằng dữ liệu kinh doanh một cách chính xác từng phần tử một có vẻ tốt. Điều đó hoàn toàn ổn đối với một trang đơn giản chỉ có một số yếu tố cơ bản như UILabel, UITextField và như vậy, chúng ta có thể truyền các thuộc tính mô hình cơ bản tới chúng và đăng ký các hành động tương tác người dùng tuỳ chỉnh trong phía controller.
Còn với một giao diện phức tạp như giao diện dựa trên UITableView, UICollectionView hoặc bất kỳ giao diện có cấu trúc sâu nào khác? Nó phải là một mô hình đặc biệt (lớp) được thiết kế cho nó, nếu không, thiết kế tồi!
Trở lại khía cạnh của OOP (Lập trình hướng đối tượng), tất cả các lớp đều là lớp model, lớp view hoặc lớp controller. Mỗi lớp nên được tái sử dụng ở đâu đó với một ý nghĩa quan trọng với thiết kế độc lập riêng biệt của nó.
- Lớp model chỉ ra logic kinh doanh tương ứng bao gồm tất cả các thuộc tính và chức năng.
- Lớp view chỉ ra có thể khởi tạo trong một controller để hiển thị giao diện với mô hình dữ liệu được xác định trước.
- Lớp controller chỉ ra trang tương ứng có thể được hiển thị với một số nguồn đầu vào.
Một lớp view không bao giờ được xây dựng trên bất kỳ logic kinh doanh nào, nếu làm như vậy, dòng dữ liệu của toàn bộ lớp view sẽ được tái cấu trúc một khi lớp model kinh doanh thay đổi, điều này thường xảy ra trong quá trình phát triển hàng ngày của chúng ta. Ngoài ra, một lớp view có thể được sử dụng lại trong một trang khác với lớp model kinh doanh khác (trong tương lai), trong trường hợp đó, nó phải kết hợp các lớp model khác nhau thành một mô hình tổng hợp không tốt hoặc chuyển đổi một mô hình sang một mô hình kinh doanh hiện có khác trước khi sử dụng lại view. Tôi không thể tưởng tượng được kết thúc tàn ác của sự đó.
Thực tế, một lớp model mô tả các thuộc tính của mô hình kinh doanh từ cơ sở dữ liệu backend, đôi khi hoàn toàn không phục vụ cho trình diễn UI. Ví dụ, có một đếm tweet chứa trong một mô hình tweet sẽ được hiển thị trong một phần tử view với định dạng ngắn khác nhau, đó là trách nhiệm của controller để xác thực và kiểm soát nội dung cuối cùng của phần tử thay vì lớp view trực tiếp.
Với ViewModel
Hãy xem cách ViewModel giải quyết các vấn đề trên.
Trong triển khai mới của lớp UIViewController con, nghĩa là controller luôn yêu cầu mô hình kinh doanh thông qua mạng hoặc cơ sở dữ liệu và giữ chúng trong suốt thời gian sống của nó, sau đó chuyển đổi chúng thành các view model mục tiêu của các view con, view model là cấu trúc chính giữa view và model thông qua controller. ViewModel cũng cung cấp các hàm gọi lại để thông báo về sự thay đổi tương tác của view cho lớp view, sau đó controller thực hiện các hành động trên model của nó...
Với thiết kế này, mô hình kinh doanh luôn luôn không hiển thị trên view, nhiệm vụ của controller là cầu nối từ mô hình kinh doanh sang đối tượng view thông qua view model. Như Apple mong đợi trong tài liệu MVC, đối tượng model chỉ tập trung vào logic kinh doanh của riêng nó, nó không quan tâm đến bất kỳ vấn đề nào liên quan đến trình bày giao diện người dùng. Đối tượng view không cần phải biết nguồn dữ liệu được hiển thị đến nó đến từ đâu.
Vậy liệu điều đó có làm cho controller trở nên rườm rà hơn? Điều đó phụ thuộc vào cách triển khai trong dự án.
ViewModel trong thực tế
Hãy xem một dự án demo thực tế về nó. Tôi đã xây dựng một ứng dụng iOS đơn giản để hiển thị các repository được đánh dấu sao trên Github thông qua open API của nó. Dưới đây là toàn bộ mã nguồn.
Cây lệnh ngắn gọn này hiển thị các tệp nguồn đầy đủ và các danh hiệu của chúng, hãy làm rõ chi tiết:
- Trong hàm
didFinishLaunchingWithOptions
của Appdelegate, nó thiết lập một tabBar controller với RepositoryViewController và SettingViewController làm root view controller. Trong SettingViewController, nó chỉ yêu cầu đầu vào của người dùng để lấy tên người dùng Github cho yêu cầu repository sau và lưu giá trị đó trong NSUserDefaults. RepositoryViewController sẽ bắt đầu yêu cầu một trang ban đầu của danh sách repository được đánh dấu sao thông qua lớp RepositoryModel và lưu kết quả trong chính nó, nó cũng tạo đối tượng view RepositoryCollectionView và chuẩn bị chuyển dữ liệu cho nó.
Hãy xem giao diện của RepositoryCollectionView trước:
@protocol RepositoryViewProtocol
@property (nonatomic, strong, readonly) NSString *title;
@property (nonatomic, strong, readonly) NSString *subtitle;
@property (nonatomic, assign) BOOL visited;
@property (nonatomic, copy, readonly) void (^ onRepositoryTapped)(void);
@end
@interface RepositoryCollectionView : UICollectionView
@property (nonatomic, strong) NSArray> *dataItems;
@property (nonatomic, copy) void (^ didScrollToEnd)(void);
@end
RepositoryCollectionView đã triển khai toàn bộ giao diện bao gồm cả lớp UICollectionViewCell con bên trong, vì vậy người gọi không cần xây dựng các yếu tố ô bổ sung khi sử dụng nó, chỉ cần gán nguồn dữ liệu được xác định trước thông qua thuộc tính dataItems là đủ, đây là kiểu RepositoryViewProtocol. Nhưng đợi đã, tại sao lại là một giao thức? tại sao không sử dụng một lớp thay thế? Đó có phải là view model chúng ta nói ở trên?
Hãy khám phá sâu hơn. Đầu tiên, id
Chúng ta không thể định nghĩa lớp view model RepositoryViewModel cho dataItems trong tệp header của view trực tiếp? Có, tôi có thể, nhưng không lịch sự như cuộc trò chuyện ở trên. Tuy nhiên, tôi (tác giả) khuyến nghị sử dụng triển khai @protocol, hãy xem các nguyên tắc không gian giao diện của nguyên tắc SOLID.
Vậy cuối cùng, người nào sẽ sử dụng giao thức cuối cùng? Lớp view model RepositoryViewModel trong các tệp nguồn cùng tên của nó sẽ làm điều đó, hãy kiểm tra cây kết quả trên, nó được định nghĩa bên ngoài của tệp RepositoryCollectionView.h/m vì không có mối quan hệ trực tiếp giữa chúng.
#import "RepositoryCollectionView.h"
@interface RepositoryViewModel : NSObject
@property (nonatomic, strong) id context;
@property (nonatomic, assign) BOOL visited;
@property (nonatomic, copy) void (^ onRepositoryTapped)(void);
@end
@implementation RepositoryViewModel
- (NSString *)title {
return nil;
}
- (NSString *)subtitle {
return nil;
}
@end
Chúng ta có thể thấy rằng thuộc tính onRepositoryTapped được xác định mặc định là readwrite ở đây, nhưng giao thức RepositoryViewProtocol yêu cầu thuộc tính chỉ đọc, đó là ý nghĩa và sự khác biệt của lớp view model và giao thức. Đối với định nghĩa thuộc tính visited, nó chỉ ra rằng lớp view cũng cần thay đổi giá trị đó để lưu trạng thái của các ô tại chính nó trong việc triển khai.
Vậy cái gì là kiểu context của thuộc tính? Nó không tồn tại trong giao thức view, tại sao chúng ta lại cần nó ở đây? Trên thực tế, đây chính là cầu nối đến mô hình kinh doanh thực tế, hãy tưởng tượng có thể có nhiều loại khác nhau của các lớp mô hình kinh doanh ánh xạ đến lớp view này, hoặc một số đối tượng mô hình chỉ tồn tại trên phía client sẽ được đặt ở đó. Với mục đích đó, chúng ta có thể giải quyết việc ánh xạ thuộc tính rất dễ dàng.
Hãy giới thiệu một macro đơn giản để chuyển các thuộc tính phù hợp đến thuộc tính view.
#define DataItemGetterForward(_source_cls_, _expr_) if ([self.context isKindOfClass:_source_cls_.class]) { return ((_source_cls_ *)self.context)._expr_; }
Sau đó sử dụng nó trong phương thức getter của title:
- (NSString *)title {
DataItemGetterForward(RepositoryModel, full_name)
return nil;
}
Đây là một kiểu tùy chọn nhưng nhẹ nhàng, nó hoạt động cho các trường hợp ánh xạ thuộc tính cầu nối đơn giản, với các biểu thức ánh xạ phức tạp hơn, nó vẫn cần được viết bằng tay. Hãy xem macro tuyệt vời khác tôi viết cho các phương thức khởi tạo view model.
#define DataItemInitializerDeclaration(_containing_cls_, _source_cls_) + (instancetype)dataItemWith ## _source_cls_: (_source_cls_ *)context; + (instancetype)dataItemWith ## _source_cls_: (_source_cls_ *)context block: (void (^ _Nullable)(_source_cls_ *source, _containing_cls_ *data))block; + (NSArray<_containing_cls_ *> *)dataItemsWith ## _source_cls_ ## s:(NSArray<_source_cls_ *> *)context; + (NSArray<_containing_cls_ *> *)dataItemsWith ## _source_cls_ ## s:(NSArray<_source_cls_ *> *)context block: (void (^ _Nullable)(_source_cls_ *source, _containing_cls_ *data))block;
#define DataItemInitializerImplementation(_containing_cls_, _source_cls_) + (instancetype)dataItemWith ## _source_cls_: (_source_cls_ *)context { return [self dataItemWith ## _source_cls_:context block:nil]; } + (instancetype)dataItemWith ## _source_cls_: (_source_cls_ *)context block: (void (^ _Nullable)(_source_cls_ *source, _containing_cls_ *data))block { _containing_cls_ *data = [self new]; data.context = context; if (block) { block(context, data); } return data; } + (NSArray<_containing_cls_ *> *)dataItemsWith ## _source_cls_ ## s:(NSArray<_source_cls_ *> *)context { return [self dataItemsWith ## _source_cls_ ## s:context block:nil]; } + (NSArray<_containing_cls_ *> *)dataItemsWith ## _source_cls_ ## s:(NSArray<_source_cls_ *> *)context block: (void (^ _Nullable)(_source_cls_ *source, _containing_cls_ *data))block { NSMutableArray<_containing_cls_ *> *items = [[NSMutableArray alloc] initWithCapacity:context.count]; for (_source_cls_ *c in context) { [items addObject:[self dataItemWith ## _source_cls_:c block:block]]; } return items; }
Sau đó sử dụng nó trong lớp view model:
@interface RepositoryViewModel : NSObject
DataItemInitializerDeclaration(RepositoryViewModel, RepositoryModel)
@end
@implementation RepositoryViewModel
DataItemInitializerImplementation(RepositoryViewModel, RepositoryModel)
@end
Bây giờ hai cặp phương thức khởi tạo sẽ sẵn sàng để chúng ta nhanh chóng chuyển đổi model RepositoryModel sang view model RepositoryViewModel, dĩ nhiên nó cũng dễ dàng mở rộng sang các model kinh doanh khác một cách nhanh chóng. Đó thật tuyệt vời, phải không?
Cuối cùng, controller sẽ tổ chức dữ liệu mô hình cho view như thế nào? Hãy lấy một cái nhìn:
self.collectionView.dataItems = [RepositoryViewModel dataItemsWithRepositoryModels:self.allRepos block:^(RepositoryModel * _Nonnull source, RepositoryViewModel * _Nonnull data) {
data.onRepositoryTapped = ^{
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"https://github.com/%@", source.full_name]];
SFSafariViewController *safariVC = [[SFSafariViewController alloc] initWithURL:url];
[self presentViewController:safariVC animated:YES completion:nil];
};
}];
Các model kinh doanh self.allRepos sẽ được ánh xạ sang lớp RepositoryViewModel và được truyền vào nguồn dữ liệu của view trực tiếp. Trong quá trình ánh xạ, nó cũng thiết lập khối tương tác người dùng tuỳ chỉnh qua thuộc tính onRepositoryTapped cho từng cell, điều này vẫn dưới sự kiểm soát của đối tượng controller.
Bây giờ hãy xem lại mối quan hệ giữa các thành phần:
- Lớp model không được sử dụng trong view hoặc giao thức view.
- Lớp view không biết sự tồn tại của view model.
- Lớp controller kiểm soát toàn bộ luồng dữ liệu từ mô hình kinh doanh đến view model.
Có làm cho lớp controller trở nên rườm rà hơn không? Không, chỉ có 124 dòng mã làm công việc này, bao gồm việc xây dựng một số views programatically, yêu cầu dữ liệu kinh doanh và chuyển đổi chúng theo trang, toàn bộ logic lớn đã được tách rời thành các thành phần khác, kết quả câu lệnh là chấp nhận được, ít nhất cho trang đơn giản này.
Hãy xem cách sử dụng thuộc tính readwrite visited, nó không xuất hiện ở bất kỳ nơi nào trong controller, model và view model, chỉ được định nghĩa trong lớp view model và được sử dụng trong lớp view chính nó. Làm thế nào để giải thích điều này? Tốt, như khai báo giao thức view, lớp view cần một thuộc tính read-write trong lớp view model để lưu trạng thái của các ô khi người dùng nhấp vào nó, sau đó cập nhật giao diện của các ô bằng trạng thái đã ghé thăm. Giao thức không thể lưu trữ dữ liệu trạng thái trực tiếp vì nó không phải là một đối tượng, vì vậy yêu cầu sự giúp đỡ từ lớp view model. Đối tượng view cung cấp chức năng này cho người gọi nhưng nó cũng có thể bị vô hiệu hóa bằng cách ghi đè các phương thức getter & setter của thuộc tính visited bằng một triển khai rỗng, không cần chỉnh sửa trong lớp view chính nó.
Nếu có điều gì đó thay đổi trong lớp model kinh doanh, chúng ta chỉ cần cập nhật mã sử dụng liên quan trong lớp view model và lớp controller, không ảnh hưởng đến lớp view. Đúng!
Nếu có một số thiết kế giao diện yêu cầu có cùng giao diện với những gì đã có, chỉ cần tạo một cầu nối mới trong lớp view model. Nó không cần phải chỉnh sửa giao thức view hoặc các cấu trúc dữ liệu nội bộ khác, đương nhiên, hãy mở rộng giao thức view nếu cần thiết.
MVVM
So với MVVM (Model-View-ViewModel), nó khác biệt như thế nào so với view model của chúng ta? Thực tế, chúng có cùng chức năng là một trừu tượng hóa của view, tiết lộ các thuộc tính công khai và hành động giao nhau ngoại trừ không có công nghệ binding. Chúng ta có thể thấy rằng tất cả các giải pháp thiết kế tương tác được xây dựng mà không có bất kỳ phép ma thuật nào trong dự án demo này, tuy nhiên MVVM trên iOS cần ReactiveCocoa framework, Microsoft cũng cung cấp khả năng binding tích hợp thông qua XAML.
Kết luận
ViewModel không chỉ dành cho mô hình MVC hoặc MVVM mà nó được thiết kế cho thành phần view, vì vậy chúng ta có thể luôn sử dụng ý tưởng này để xây dựng một lớp view hướng đối tượng.