modular_architecture_on_ios

Lý thuyết cần thiết về các thư viện của Apple và một số kiến thức cơ bản đã được giải thích. Cuối cùng, đã đến lúc đi sâu vào giai đoạn xây dựng.

Trước tiên, chúng ta sẽ thực hiện thủ công và sau đó tự động hóa quy trình tạo thư viện, để những người mới không phải sao chép dán quá nhiều mã mẫu khi bắt đầu một nhóm mới hoặc một phần mới của khung ứng dụng.

Để minh họa, tôi chọn ứng dụng Cosmonaut cùng với tất cả các thư viện phụ thuộc cần thiết. Tuy nhiên, nguyên tắc tương tự có thể áp dụng cho tất cả các ứng dụng khác trong khung nền tảng iOS/macOS ISS tương lai của chúng ta.

Bạn có thể xem thư mục iss_modular_architecture và tập trung hoàn toàn vào các bước giải thích trong sách, hoặc bạn cũng có thể tự xây dựng đến một mức độ nhất định.

https://github.com/CyrilCermak/modular_architecture_on_ios/tree/master/iss_modular_architecture/app/Cosmonaut

Xin nhắc lại, sơ đồ sau đây trình bày ứng dụng Cosmonaut cùng với các thư viện phụ thuộc của nó.

Tạo cấu trúc workspace
Trước tiên, chúng ta sẽ tạo thủ công ứng dụng Cosmonaut từ Xcode trong thư mục iss_application_framework/app/. Để thực hiện điều này, chỉ cần tạo một ứng dụng mới từ menu của Xcode và lưu nó vào đúng đường dẫn thư mục đã định sẵn với tên là Cosmonaut. Một project ứng dụng trống sẽ được tạo ra, bạn có thể chạy thử nếu muốn.

Tuy nhiên, đối với mục đích của chúng ta, cấu trúc project như vậy là chưa tối ưu. Chúng ta sẽ làm việc trong một workspace chứa nhiều project khác nhau (gồm cả ứng dụng và các framework).

Vì hiện tại chúng ta chưa sử dụng CocoaPods, công cụ có thể tự động chuyển project thành một workspace, nên cần phải thực hiện thao tác này thủ công. Trong Xcode, vào menu File, chọn Save As Workspace. Sau đó, đóng project lại và mở workspace mới được Xcode tạo ra. Cho đến lúc này, workspace chỉ chứa duy nhất ứng dụng App. Giờ là lúc tạo các thư viện phụ thuộc cần thiết cho ứng dụng Cosmonaut.

Dựa theo sơ đồ, lớp đầu tiên từ trên xuống là Domain layer, nơi cần tạo các thành phần Spacesuit, Cosmonaut, và Scaffold. Để tạo Spacesuit, chúng ta sẽ sử dụng Xcode thêm một lần nữa. Trong phần tạo project mới, chọn biểu tượng Framework, đặt tên là Cosmonaut, và lưu vào thư mục iss_application_framework/domain/.

Tự động hóa quy trình

Mặc dù việc tạo mới các framework và ứng dụng không phải là công việc diễn ra hằng ngày, quy trình này vẫn cần đảm bảo rằng các namespace và quy tắc đặt tên được tuân thủ nhất quán trong toàn bộ kiến trúc ứng dụng. Điều này thường dẫn đến việc sao chép một framework hoặc ứng dụng có sẵn để tạo một cái mới theo cùng một mẫu. Bây giờ là thời điểm thích hợp để tạo script đầu tiên hỗ trợ phát triển kiến trúc ứng dụng.

Nếu bạn đang xây dựng kiến trúc ứng dụng từ đầu, vui lòng sao chép thư mục {PROJECT_ROOT}/fastlane từ kho mã nguồn vào thư mục gốc của bạn.

Việc sử dụng Fastlane để viết script cho kiến trúc ứng dụng sẽ được giải thích kỹ hơn ở phần sau của sách. Tuy nhiên, hiện tại bạn chỉ cần biết rằng Fastlane có một lane tên là make_new_project, nhận vào ba tham số: type (app hoặc framework), project_namedestination_path.

Lane này trong Fastlane đơn giản là sử dụng một thể hiện của lớp ProjectFactory, nằm trong file {PROJECT_ROOT}/fastlane/scripts/ProjectFactory/project_factory.rb.

ProjectFactory sẽ tạo một framework hoặc app mới dựa trên tham số type được truyền từ dòng lệnh. Ví dụ, để tạo framework domain Spacesuit, có thể sử dụng lệnh sau:

fastlane make_new_project type:framework project_name:Spacesuit destination_path:../domain/Spacesuit

Trong trường hợp Fastlane chưa được cài đặt trên máy Mac của bạn, bạn có thể cài đặt nó thông qua lệnh brew install fastlane hoặc sau này thông qua Ruby gems được định nghĩa trong file Gemfile. Vui lòng làm theo hướng dẫn cài đặt chính thức để thực hiện.

https://docs.fastlane.tools/getting-started/ios/setup

Ngoài ra, giờ đây khi chúng ta đã có script, tất cả các thư viện phụ thuộc còn lại đều có thể được tạo ra bằng cách sử dụng script này.

Tổng thể Kiến trúc Ứng dụng ISS sẽ có cấu trúc như sau:

Mỗi thư mục chứa một project Xcode, là một framework hoặc ứng dụng được tạo bởi script.

Từ giờ trở đi, tất cả các nhóm hoặc lập trình viên mới tham gia dự án đều nên sử dụng script này khi thêm một framework hoặc ứng dụng mới.

Workspace của Xcode
Cuối cùng nhưng không kém phần quan trọng, chúng ta sẽ tạo cấu trúc thư mục tương tự trong Workspace của Xcode, để sau này có thể liên kết các framework lại với nhau và với ứng dụng.

Workspace Cosmonaut.xcworkspace nằm trong thư mục Cosmonaut, bên trong thư mục app. Một xcworkspace đơn giản là một cấu trúc bao gồm:

  • xcshareddata: Thư mục chứa các scheme, breakpoint và các thông tin dùng chung khác
  • xcuserdata: Thư mục chứa thông tin về trạng thái giao diện người dùng hiện tại, các tệp đang mở hoặc đã chỉnh sửa của người dùng, v.v.
  • contents.xcworkspacedata: Tệp XML mô tả các project được liên kết với workspace để Xcode có thể hiểu và xử lý đúng cách

Cấu trúc workspace có thể được tạo bằng cách kéo và thả tất cả các project framework cần thiết cho ứng dụng Cosmonaut, hoặc bằng cách chỉnh sửa trực tiếp tệp XML contents.xcworkspacedata.

Dù bạn chọn cách nào, workspace .xcworkspace cuối cùng cũng nên có cấu trúc như sau:

Tạo các project

Bạn có thể đã để ý thấy file project.yml được tạo cùng với mỗi framework hoặc ứng dụng. File này được sử dụng bởi XcodeGen (sẽ được giới thiệu ngay sau đây) để tạo project dựa trên các thiết lập được mô tả trong file YAML. Việc này giúp tránh các xung đột trong file project.pbxproj khét tiếng của Apple – file đại diện cho mỗi project.

Trong kiến trúc mô-đun, điều này đặc biệt hữu ích vì chúng ta phải làm việc với nhiều project trong cùng một workspace.

Xung đột trong các file project.pbxproj rất thường xảy ra khi có nhiều lập trình viên cùng làm việc trên một mã nguồn. Ngoài các thiết lập build cho project, file này còn theo dõi các tệp được đưa vào biên dịch và các target tương ứng của chúng. Một xung đột điển hình xảy ra khi một lập trình viên xoá một file khỏi cấu trúc Xcode trong khi lập trình viên khác đang chỉnh sửa file đó trong một nhánh riêng biệt. Điều này sẽ gây ra xung đột khi merge, rất tốn thời gian để xử lý vì pbxproj sử dụng cú pháp khó hiểu mà gần như không ai hiểu rõ.

Vì lập trình viên thường “lười”, nên cũng rất hay xảy ra trường hợp một file đã bị xoá khỏi project trong Xcode nhưng vẫn còn nằm trong repository, do chưa bị xoá khỏi ổ đĩa. Điều này có thể khiến Git tiếp tục theo dõi một file không còn được sử dụng nữa, và thậm chí file đó có thể bị thêm lại vào project bởi lập trình viên khác đang làm việc trên nó.

Chào mừng đến với XcodeGen
May mắn thay, trong hệ sinh thái của Apple, chúng ta có thể sử dụng XcodeGen — một chương trình giúp tạo ra file pbxproj dựa trên file YAML được tổ chức rõ ràng.

Để sử dụng XcodeGen, trước tiên bạn cần cài đặt nó bằng lệnh brew install xcodegen hoặc bằng các phương pháp khác được mô tả trên trang chủ của XcodeGen.

https://github.com/yonaskolb/XcodeGen

Ví dụ, hãy cùng xem qua file project.yml của ứng dụng Cosmonaut:

Mặc dù file YAML khá dễ hiểu, nhưng hãy để tôi giải thích một vài phần trong đó.

Trước hết, hãy nhìn vào dòng include ở ngay phần đầu của file.

Trước khi XcodeGen bắt đầu tạo file pbxproj, nó sẽ xử lý và bao gồm các file YAML khác nếu phát hiện từ khóa include. Trong kiến trúc ứng dụng framework, điều này đặc biệt hữu ích vì các thiết lập build cho từng project có thể được mô tả chỉ trong một file YAML duy nhất.

Hãy tưởng tượng một tình huống: bạn cần nâng cấp phiên bản iOS deployment cho ứng dụng. Vì ứng dụng này liên kết với nhiều framework khác và các framework đó cũng được biên dịch trước khi app được build, nên tất cả các deployment target của các framework đó cũng phải được nâng lên. Nếu không sử dụng XcodeGen, bạn sẽ phải sửa đổi thủ công từng project một để thay đổi deployment target. Tệ hơn nữa, khi muốn thử nghiệm một số thiết lập build, thay vì phải chỉnh từng project, bạn chỉ cần thay đổi một file YAML duy nhất được các file khác include là đủ.

Một file YAML đơn giản chứa các thiết lập build có thể trông như sau:

Cần lưu ý rằng trong phần BuildSettings, các khóa trong file YAML tương ứng trực tiếp với các thiết lập build của Xcode, mà bạn có thể xem trong bảng inspector bên cạnh.

Như bạn thấy, khóa BuildSettings sau đó được tham chiếu bên trong file project.yml dưới phần settings, ngay sau tên project.

Khóa tiếp theo là targets. Trong trường hợp ứng dụng Cosmonaut, chúng ta thiết lập ba target: một cho ứng dụng chính, một cho unit test và cuối cùng là một cho UI test. Mỗi target được đặt tên và mô tả với các thông số như loại (type), nền tảng (platform), các phụ thuộc (dependencies) và các tham số khác mà XcodeGen hỗ trợ.

Tiếp theo, hãy cùng xem phần dependencies:

Phần dependencies liên kết các framework được chỉ định với ứng dụng. Trong đoạn mã phía trên, bạn có thể thấy ứng dụng đang sử dụng những phụ thuộc nào. Từ khóa implicit với một framework nghĩa là framework đó chưa được biên dịch sẵn và cần được biên dịch để có thể sử dụng. Điều này đồng nghĩa framework cần phải là một phần trong workspace để hệ thống build hoạt động chính xác.

Một tham số khác có thể được khai báo là embedded: {true|false}. Tham số này xác định xem framework có được nhúng vào ứng dụng và sao chép vào target hay không. Theo mặc định, XcodeGen đặt embedded: true cho các ứng dụng vì chúng phải sao chép framework đã biên dịch vào target để ứng dụng có thể khởi chạy thành công, và embedded: false cho các framework. Vì framework không phải là một chương trình thực thi độc lập mà phải là một phần của ứng dụng nào đó, nên việc ứng dụng sao chép framework là điều tất yếu.

Tài liệu đầy đủ về XcodeGen có thể tìm thấy trên trang GitHub của nó.

https://github.com/yonaskolb/XcodeGen

Cuối cùng, hãy tạo project và build ứng dụng cùng tất cả các framework. Để làm điều này, một lane đơn giản trong Fastlane đã được tạo.

Chỉ cần thực thi lệnh fastlane generate trong thư mục gốc của kiến trúc ứng dụng, tất cả các project sẽ được tạo ra. Sau đó, bạn có thể mở workspace và nhấn Run để chạy ứng dụng.

Kết quả đầu ra của lệnh này sẽ trông giống như sau:

Nguyên tắc cơ bản

Khi quan sát kiến trúc ISS, có hai nguyên tắc quan trọng đang được tuân thủ:

Trước hết, bất kỳ framework nào cũng KHÔNG được phép liên kết (link) với các module nằm cùng một tầng (layer). Nguyên tắc này nhằm ngăn chặn việc tạo ra các vòng liên kết chéo giữa các framework. Ví dụ, nếu module Network liên kết với module Radio, và ngược lại module Radio cũng liên kết với Network, thì sẽ gây ra vấn đề nghiêm trọng. Điều bất ngờ là Xcode không phải lúc nào cũng báo lỗi khi gặp trường hợp như vậy — tuy nhiên, quá trình biên dịch và liên kết sẽ rất khó khăn, và đến một lúc nào đó sẽ bắt đầu thất bại hoàn toàn.

Nguyên tắc thứ hai là: mỗi layer chỉ được phép liên kết với các framework từ các layer con của nó. Nguyên tắc này giúp đảm bảo mô hình liên kết theo chiều dọc. Điều đó cũng có nghĩa là các phụ thuộc chéo (cross-linking) sẽ không xảy ra theo chiều dọc.

Hãy cùng xem qua một vài ví dụ về các phụ thuộc chéo không hợp lệ.

Phụ thuộc chéo (Cross-linking dependencies)

Giả sử hệ thống build bắt đầu biên dịch module Network, trong đó có liên kết đến module Radio. Khi đến bước cần liên kết với Radio, hệ thống chuyển sang biên dịch module Radio trước khi hoàn tất việc biên dịch Network.

Tuy nhiên, Radio lại cần sử dụng Network để tiếp tục biên dịch. Nhưng lúc này Network vẫn chưa hoàn tất, nên các file như swiftmodule và các file biên dịch khác vẫn chưa được tạo ra. Trình biên dịch vẫn tiếp tục tiến trình cho đến khi một file trong module này tham chiếu đến một phần (ví dụ: một lớp trong file) thuộc module kia, và ngược lại — lúc này cả hai module đang tham chiếu lẫn nhau.

Đó là thời điểm mà trình biên dịch sẽ dừng lại và báo lỗi.

Không cần phải nói thêm, mỗi layer được định nghĩa là bao gồm các module độc lập, chỉ cần một số phụ thuộc ở tầng con. Về lý thuyết điều này hoàn toàn hợp lý và có tính tổ chức. Tuy nhiên, trong thực tế, có thể xảy ra trường hợp một domain cần sử dụng nội dung từ một domain khác (ví dụ: domain Cosmonaut cần một thành phần từ domain Spacesuit). Điều này có thể bao gồm logic đặc thù của domain, view, hoặc thậm chí là toàn bộ flow màn hình.

Trong những trường hợp như vậy, có một số cách để xử lý:

  1. Tạo một module mới ở tầng Service, và di chuyển các file mã nguồn cần chia sẻ giữa nhiều domain vào đó.
  2. Di chuyển trực tiếp các file cần thiết từ layer Domain sang layer Service, nếu không cần giữ lại trong domain cũ.
  3. Sử dụng tính trừu tượng (abstraction) để đạt được kết quả tương tự, nhưng ở cấp độ mã nguồn thay vì cấp độ module.

Giải pháp lý tưởng phụ thuộc hoàn toàn vào tình huống cụ thể.

Ngoài ra, còn một phương án khác sẽ được giới thiệu trong phần tiếp theo — tách các interface ra thành một framework riêng biệt.

Một ví dụ đơn giản có thể là một flow (luồng xử lý) được biểu diễn bằng một protocol có hàm start(). Đây có thể là một pattern coordinator, được định nghĩa chung cho toàn bộ framework và tất cả các module sẽ tuân theo nó.

Protocol đó cần được định nghĩa trong một framework thuộc layer thấp hơn. Trong trường hợp này, vì liên quan đến luồng của các view controller, nên framework UIComponents có thể là một nơi phù hợp để đặt nó.

Nhờ đó, tất cả các domain trong framework có thể hiểu và sử dụng được protocol này một cách nhất quán. Sau đó, ứng dụng Cosmonaut có thể khởi tạo một coordinator từ domain Spacesuit, rồi truyền xuống hoặc gán nó làm child coordinator của domain Cosmonaut.

Liên kết theo chiều dọc (Vertical linking)

Tương tự như liên kết theo chiều ngang giữa các layer, liên kết theo chiều dọc cũng rất quan trọng và cần được tuân thủ để tránh các vấn đề biên dịch đã nêu trước đó. Trong thực tế, tình huống vi phạm nguyên tắc này cũng rất dễ xảy ra.

Hãy tưởng tượng nhóm của bạn thiết kế một framework mới ở tầng Core, cung cấp chức năng mở rộng cho việc ghi log và phân tích dữ liệu. Sau một thời gian, một nhóm khác muốn sử dụng chức năng log này, ví dụ trong module Radio, để cung cấp thêm thông tin gỡ lỗi cho module Bluetooth.

Không giống như trường hợp phụ thuộc chéo (cross-linking), lần này tầng trừu tượng đã được định nghĩa sẵn ở tầng Core. Vì vậy, không thể truyền tham chiếu từ trên xuống trong mã nguồn như thường lệ.

Trong trường hợp này, cần tạo thêm một layer mới, ví dụ gọi là Shared hoặc Common. Layer hỗ trợ này sẽ chủ yếu chứa các chức năng dùng chung cho tầng Core, cũng như các protocol cho phép truyền tham chiếu từ trên xuống dưới một cách có tổ chức.

Một giải pháp khác là tách riêng các protocol và model public của framework tầng Core ra, để framework này có thể được expose (công khai) và liên kết với nhiều framework khác nằm cùng tầng.

Ở tầng cao hơn, quá trình khởi tạo (instantiation) sẽ diễn ra và các instance sẽ được truyền xuống tầng thấp hơn, vì cả hai tầng đều đã liên kết tới framework Core mới được tạo ra cho module đó.

Tuy nhiên, cách làm này có nhược điểm là cần thêm một framework riêng, đồng nghĩa với việc phải duy trì và quản lý thêm một thành phần nữa.

Dù vậy, với cách tiếp cận này, bạn đang tuân thủ Clean Architecture (Kiến trúc sạch) — chủ đề sẽ được nói rõ hơn ở phần tiếp theo.

Dĩ nhiên, bất kỳ framework nào ở tầng cao hơn đều có thể liên kết tới bất kỳ framework nào từ tầng thấp hơn. Ví dụ, ứng dụng Cosmonaut có thể liên kết với bất kỳ framework nào từ tầng Core hoặc tầng Shared mới được định nghĩa.

Core Framework

Trong phần lớn các trường hợp, kiến trúc đã được mô tả cho đến giờ là đủ dùng. Tuy nhiên, khi các nhóm và Application Framework ngày càng mở rộng, sẽ xuất hiện ngày càng nhiều tình huống mà các service cần phải tương tác với nhau.

Tất nhiên, luôn có khả năng kết nối các service ở tầng cao hơn — trong trường hợp này, chúng có thể tương tác ở tầng domain, hoặc nếu có nhiều domain cùng cần đến, thì có thể ở tầng app.

Hãy xem một ví dụ cụ thể:
Giả sử có một CosmonautService, được trừu tượng hóa bằng protocol công khai là CosmonautServicing, và một SpacesuitService được trừu tượng hóa bởi SpacesuitServicing. Cả hai service này được triển khai trong framework tương ứng của chúng.

Một nhu cầu hợp lý về mặt kiến trúc là CosmonautService cần tương tác với SpacesuitService — nghĩa là nó cần biết interface public của SpacesuitService để có thể truyền vào một instance tuân theo protocol đó.

Cách đơn giản nhất là kết nối hai service này qua callback ở tầng domain hoặc tầng app — ví dụ: một output từ CosmonautService được quan sát và dùng để kích hoạt chức năng tương ứng của SpacesuitService.

Một vài trường hợp như vậy thì không sao, nhưng khi số lượng tương tác ngày càng tăng, mã nguồn sẽ trở nên rối rắm và khó bảo trì.

May mắn thay, chúng ta có thể cải thiện kiến trúc hiện tại bằng cách giới thiệu một framework mới có thể gọi là Core framework.

Sử dụng Core Framework

Core framework thực chất là một framework target phụ nằm trong Xcode project của SpacesuitService, tức là framework chính. Trong ví dụ về SpacesuitService, framework core sẽ được đặt tên là SpacesuitServiceCore.

Tuy nhiên, SpacesuitServiceCore cần phải tuân theo một bộ quy tắc riêng để tránh các vấn đề biên dịch chéo (cross compile).

Trong XcodeGen, framework Core có thể được định nghĩa như sau:

Do đó, SpacesuitServiceCore có thể được liên kết và sử dụng trong framework CosmonautService, giúp cho tất cả các protocol và kiểu dữ liệu public từ SpacesuitServiceCore có thể được truy cập bởi CosmonautService.

Trong Xcode, một target mới sẽ xuất hiện trong danh sách các target có sẵn và nằm trong project Cosmonaut.

Code Toàn Bug

Code nhiều bug nhưng biết cách giấu!

Leave a Reply

Your email address will not be published. Required fields are marked *