Chẻ một ứng dụng thành các Swift package cục bộ
Tôi chẻ một ứng dụng 60.000 dòng thành mười một Swift package cục bộ trong khoảng ba tuần. Việc đó đáng làm, nó không phải khoản lợi miễn phí như các bài viết gợi ra, và ở lần đầu tôi làm sai cấu trúc phụ thuộc theo một cách mất cả tuần để gỡ.
Nó thật sự đem lại gì
Build tăng dần nhanh hơn. Sửa một view thì build lại module tính năng của nó và cái vỏ ứng dụng, chứ không phải cả thế giới. Từ 90 giây xuống khoảng 25.
Ranh giới được thi hành. Đây mới là phần thưởng thật, và là thứ sống lâu hơn cả cái lợi về thời gian build. Một module không dùng được thứ gì mà nó chưa khai báo phụ thuộc, nên lối tắt “cứ import đại vào rồi tính sau” trở thành một lỗi biên dịch. Những quy tắc kiến trúc vốn nằm trong một trang wiki giờ được trình biên dịch thi hành.
Preview chạy được. Một module tính năng với đồ thị phụ thuộc nhỏ thì build đủ nhanh để preview của SwiftUI dùng được. Trong khối nguyên liền, chúng hết giờ liên tục, nghĩa là chẳng ai dùng, nghĩa là vòng phản hồi thiết kế là “chạy cả ứng dụng lên”.
Test chạy trong vài giây. Kiểm thử một module thì biên dịch một module.
Cấu trúc đã hiệu quả
Bốn tầng, với phụ thuộc chỉ bao giờ trỏ xuống dưới:
App — cái vỏ, phần nối dây, app delegate
↓
Features/ — Search, Library, Settings, Reader
↓
Core/ — Networking, Persistence, Analytics
↓
Foundation/ — DesignSystem, Extensions, Models
Các quy tắc:
- Feature không bao giờ import feature khác. Thứ gì dùng chung thì chuyển xuống một tầng.
- Core không bao giờ import Features. Nếu Core cần thứ gì từ một feature thì thiết kế đang sai.
- Foundation không import thứ gì của chúng ta.
Sai lầm
Lần thử đầu tiên của tôi có một module Shared mà mọi thứ đều phụ thuộc vào. Nó bắt đầu với ba kiểu
và phình lên khoảng tám mươi, vì “dùng chung” chẳng có định nghĩa nào và cái gì cũng dùng chung được
nếu bạn nheo mắt lại.
Trong vòng hai tháng, Shared import phần mạng (cho một model API), design system (cho một enum màu)
và phần lưu trữ (cho một hàm trợ giúp Codable). Mọi module phụ thuộc vào Shared, nên mọi module
gián tiếp phụ thuộc vào tất cả. Thời gian build quay về đúng chỗ xuất phát và các ranh giới thôi mang
ý nghĩa gì.
Cảnh báo
Một module tên Shared, Common, Core hay Utils mà không có quy tắc nào nói rõ thứ gì thuộc
về nó sẽ trở thành một cục nam châm hút phụ thuộc. Cách chữa là một cái tên loại trừ được các thứ:
DesignSystem nói cho bạn biết thứ gì không thuộc về nó; Shared thì không.
Gỡ nó ra mất một tuần: chẻ Shared thành Models, DesignSystem và Extensions, rồi sửa lại mọi
lệnh import. Tôi thà bỏ một tiếng ra nghĩ tên còn hơn.
Những cái giá chẳng ai nhắc tới
Tài nguyên cần được khai báo. Asset, chuỗi đã dịch và mọi file đóng gói kèm đều cần một mục
resources: trong Package.swift, và cần Bundle.module thay vì Bundle.main. Bỏ sót điều này thì
hỏng lúc chạy, không phải lúc build — một tấm ảnh thiếu là một khoảng trắng trong bản phát hành.
.target(
name: "DesignSystem",
resources: [.process("Resources")]
)
Kiểm soát truy cập trở thành việc thật. Mọi thứ mặc định là internal, thứ giờ đây bị giới hạn
trong phạm vi module. Mọi kiểu mà module khác dùng đều cần public, và mọi bộ khởi tạo cũng vậy —
một public struct với init được sinh tự động thì không dựng được từ bên ngoài, và thông báo lỗi
không giải thích vì sao.
Phụ thuộc vòng là một cuộc tái cấu trúc. Hai feature muốn tham chiếu lẫn nhau thì không được, và lỗi của SPM nói cho bạn biết có một cái vòng nhưng không nói phải làm gì với nó. Câu trả lời luôn là tách phần dùng chung xuống dưới hoặc đảo chiều phụ thuộc bằng một protocol, và cả hai đều tốn thời gian thật.
Xcode chậm đi ở vài việc. Việc lập chỉ mục qua nhiều package, nhảy tới định nghĩa, và giải phụ
thuộc sau khi đổi Package.swift đều chậm hơn thấy rõ so với một khối nguyên liền.
Tĩnh hay động
Hãy mặc định là tĩnh. Framework động được nạp bằng dlopen lúc khởi động, và mỗi cái tốn những mili
giây có thật — đây là thứ đóng góp lớn nhất vào việc khởi động nguội chậm trong chính ứng dụng đó.
.library(name: "DesignSystem", type: .static, targets: ["DesignSystem"])
Bỏ hẳn phần type: đi thì SPM tự quyết, và như vậy là ổn với phần lớn module. Chỉ ghi rõ .dynamic
khi một module thật sự được dùng chung với một app extension — nếu không thì code bị liên kết vào cả
hai binary.
Có nên làm không
Đáng làm nếu ứng dụng hơn khoảng 30.000 dòng, có hơn hai ba người cùng làm, thời gian build đang gây đau, hoặc kiến trúc liên tục bị vi phạm vì chẳng có gì thi hành nó.
Không đáng với một ứng dụng một người dưới khoảng 20.000 dòng. Việc bảo trì ranh giới là một cái giá thường trực có thật và cái lợi về thời gian build thì nhỏ khi chẳng có mấy thứ để build lại.
Hãy làm dần dần. Tách ra một module lá — một design system hay một package model, thứ gì đó không có phụ thuộc — rồi sống với nó một tuần trước khi tách cái tiếp theo. Cám dỗ lên kế hoạch cả mười một module ngay từ đầu thì rất mạnh, và mọi kế hoạch tôi từng lập theo cách đó đều đoán sai chỗ mà ranh giới thật sự rơi vào.