移动 App 做到一定规模以后,你会发现:
- 加一个小需求,要改一堆地方;
- 任何一个业务改动,都要整个 App 全量回归;
- 编译时间、打包时间越来越长,CI 队列排队;
- 想抽组件、做中台,结果越抽越乱,组件之间互相依赖;
- 想给某个业务线做「独立壳工程」,结果带上半个世界的依赖。
所以,大厂的 App 架构演进路径几乎都很像:
单体工程 → 多模块 → 单仓多组件(subspec)→ 业务接口层 / 服务层
→ 中台化(Infra / 设计系统 / 基础服务)→ 子壳工程 → 自动化治理 / lint
这篇文章基于抖音的《抖音 iOS 工程架构演进》那篇文章,做了一套可跑的 Demo 工程,尽量向「阶段三 + 阶段四」靠拢,包括:
- 单仓多组件(pod + subspec)
- 业务接口层(ModuleInterface)
- 服务层接口 + 多实现(ServiceInterface:Online / Mock)
- 中台层(Infra / UIResources / UITheme)
- 子壳工程(SearchShell / FeedShell)
- 启动框架(LaunchKit + BootTask + ServiceRegistry + Router)
希望这套 Demo 能帮你把文章里的概念变成 真正能跑的工程,也让你在自己项目里更容易落地类似的架构。
1. 整体目标:我们到底要搭一套什么样的架构?
目标拆一下:
-
单仓多组件
- 一个仓库,多个 Pod / 多个 subspec
- 每个业务线仓库下有:
ModuleInterface / Service / BizUI / Model / Basic / Impl
-
业务域拆分
- 两个核心业务:Feed / Search
- 支持跨业务调用(Feed 调 Search)
- 调用通过 接口层 + 服务层 完成
-
两层接口
- 业务接口层(ModuleInterface):对外暴露入口能力
- 服务层接口(ServiceInterface):封装公共业务逻辑,可替换底层能力(Online/Mock)
-
中台层
Infra:用户服务 / 网络服务UIResources:公共资源(icon / 图片 bundle)UITheme:统一主题(颜色 / 字体 / BaseVC / BaseNav)
-
子壳工程
SearchShellApp:专门给 Search 业务线用的壳工程FeedShellApp:专门给 Feed 业务线用的壳工程- 不同壳可以选择不同 Service 实现(Mock / Online),最小化依赖
-
启动框架
- LaunchKit:BootPhase + BootTask
- ServiceRegistry:服务总线
- Router:字符串路由(
douyin://search、douyin://feed)
2. 工程结构总览
目录结构大致如下(省略部分细节):
text
DouyinArchDemo/
├── Podfile
├── LaunchKit/ # 启动框架 & ServiceRegistry & Router
│ ├── AWELaunchKit.podspec
│ └── Sources/
├── Infra/ # 中台逻辑:用户 / 网络
│ ├── AWEInfra.podspec
│ └── Sources/
├── UIResources/ # 中台资源:通用 icon 等
│ ├── AWEUIResources.podspec
│ ├── Assets/
│ │ └── AWEUIIcons.xcassets
│ └── Sources/
├── UITheme/ # 中台主题:颜色、字体、BaseVC、BaseNav
│ ├── AWEUITheme.podspec
│ └── Sources/
├── Modules/
│ ├── Search/ # 搜索业务域
│ │ ├── AWESearch.podspec
│ │ ├── ModuleInterface/
│ │ ├── Basic/
│ │ ├── Model/
│ │ ├── Service/
│ │ ├── BizUI/
│ │ └── Impl/
│ └── Feed/ # Feed 业务域
│ ├── AWEFeed.podspec
│ ├── ModuleInterface/
│ ├── Service/
│ ├── BizUI/
│ └── Impl/
└── Apps/
├── DouyinHostApp/ # 宿主 App
├── SearchShellApp/ # 搜索子壳
└── FeedShellApp/ # Feed 子壳
3. 启动框架:LaunchKit + BootTask + ServiceRegistry + Router
3.1 BootPhase & BootTask:让启动过程可编排
swift
public enum BootPhase: Int {
case preInfra = 0
case infra = 1 // 中台服务初始化(User / Network / Theme)
case bizRegister = 2 // 各业务注册入口、路由、服务
case postUI = 3 // UI 之后的收尾
}
open class BootTask {
public let phase: BootPhase
public let priority: Int
public init(phase: BootPhase, priority: Int = 0) {
self.phase = phase
self.priority = priority
}
open func execute(completion: @escaping () -> Void) {
completion()
}
}
3.2 LaunchKit:按 Phase + Priority 执行任务
swift
public enum LaunchKit {
private static var tasks: [BootTask] = []
private static var hasRunPhases = Set<BootPhase>()
public static func register(task: BootTask) {
tasks.append(task)
}
public static func run(phase: BootPhase, completion: @escaping () -> Void) {
guard !hasRunPhases.contains(phase) else {
completion()
return
}
hasRunPhases.insert(phase)
let phaseTasks = tasks
.filter { $0.phase == phase }
.sorted { $0.priority > $1.priority }
runTasksSequentially(phaseTasks, index: 0, completion: completion)
}
private static func runTasksSequentially(_ tasks: [BootTask],
index: Int,
completion: @escaping () -> Void) {
guard index < tasks.count else {
completion()
return
}
let task = tasks[index]
task.execute {
runTasksSequentially(tasks, index: index + 1, completion: completion)
}
}
}
3.3 ServiceRegistry:统一管理服务实例
swift
public final class ServiceRegistry {
public static let shared = ServiceRegistry()
private var store: [String: Any] = [:]
private let lock = NSLock()
private init() {}
public func register<T>(_ type: T.Type, impl: T) {
let key = String(reflecting: type)
lock.lock()
store[key] = impl
lock.unlock()
}
public func resolve<T>(_ type: T.Type) -> T? {
let key = String(reflecting: type)
lock.lock()
let value = store[key] as? T
lock.unlock()
return value
}
}
3.4 Router:统一路由入口
swift
public final class Router {
public static let shared = Router()
public typealias RouteHandler = (_ params: [String: Any]?) -> UIViewController?
private var routes: [String: RouteHandler] = [:]
private init() {}
public func register(_ path: String, handler: @escaping RouteHandler) {
routes[path] = handler
}
public func open(_ path: String,
from navigationController: UINavigationController?,
params: [String: Any]? = nil,
animated: Bool = true) {
guard let vc = routes[path]?(params) else { return }
navigationController?.pushViewController(vc, animated: animated)
}
}
4. 中台层:Infra / UIResources / UITheme
4.1 Infra:用户 & 网络
swift
public protocol UserService {
var isLoggedIn: Bool { get }
var userName: String? { get }
func login(name: String, completion: @escaping (Bool) -> Void)
}
public final class DefaultUserService: UserService {
public private(set) var isLoggedIn = false
public private(set) var userName: String?
public func login(name: String, completion: @escaping (Bool) -> Void) {
isLoggedIn = true
userName = name
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
completion(true)
}
}
}
swift
public protocol NetworkClient {
func request(path: String,
query: [String: String],
completion: @escaping (Result<[String], Error>) -> Void)
}
public final class StubNetworkClient: NetworkClient {
public func request(path: String,
query: [String : String],
completion: @escaping (Result<[String], Error>) -> Void) {
let keyword = query["q"] ?? ""
let results = (1...5).map { "Result \($0) for \(keyword)" }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
completion(.success(results))
}
}
}
swift
public final class InfraBootTask: BootTask {
public override func execute(completion: @escaping () -> Void) {
ServiceRegistry.shared.register(UserService.self, impl: DefaultUserService())
ServiceRegistry.shared.register(NetworkClient.self, impl: StubNetworkClient())
completion()
}
}
public func registerInfraBootTasks() {
LaunchKit.register(task: InfraBootTask(phase: .infra, priority: 100))
}
4.2 UIResources:通用资源 Pod
swift
public enum AWEUIResources {
private static var bundle: Bundle = {
let bundle = Bundle(for: BundleToken.self)
if let url = bundle.url(forResource: "AWEUIResources", withExtension: "bundle"),
let resourceBundle = Bundle(url: url) {
return resourceBundle
}
return bundle
}()
public static var navBackIcon: UIImage? {
UIImage(named: "ic_nav_back", in: bundle, compatibleWith: nil)
}
}
private final class BundleToken {}
业务模块的私有资源也通过各自的封装访问,例如:
swift
public enum SearchResources {
private static var bundle: Bundle = {
let bundle = Bundle(for: BundleToken.self)
if let url = bundle.url(forResource: "AWESearchBizUIResources", withExtension: "bundle"),
let resourceBundle = Bundle(url: url) {
return resourceBundle
}
return bundle
}()
public static var searchTabIcon: UIImage? {
UIImage(named: "ic_search_tab", in: bundle, compatibleWith: nil)
}
}
private final class BundleToken {}
4.3 UITheme:统一样式 + BaseVC / BaseNav
swift
public enum AWEColor {
public static var navBackground: UIColor { .white }
public static var navTitle: UIColor { .black }
public static var navTint: UIColor { .systemBlue }
public static var viewBackground: UIColor { .systemBackground }
}
public enum AWEFont {
public static var navTitle: UIFont {
.systemFont(ofSize: 17, weight: .semibold)
}
}
swift
public final class AWENavigationController: UINavigationController {
public override func viewDidLoad() {
super.viewDidLoad()
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = AWEColor.navBackground
appearance.titleTextAttributes = [
.foregroundColor: AWEColor.navTitle,
.font: AWEFont.navTitle
]
navigationBar.standardAppearance = appearance
navigationBar.scrollEdgeAppearance = appearance
navigationBar.compactAppearance = appearance
navigationBar.tintColor = AWEColor.navTint
}
}
swift
open class AWEBaseViewController: UIViewController {
open override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = AWEColor.viewBackground
configureNavigationBar()
}
open func configureNavigationBar() {
if let nav = navigationController,
nav.viewControllers.first !== self {
navigationItem.leftBarButtonItem = UIBarButtonItem(
image: AWEUIResources.navBackIcon,
style: .plain,
target: self,
action: #selector(onBack)
)
}
}
@objc open func onBack() {
navigationController?.popViewController(animated: true)
}
}
5. Search 模块:ModuleInterface + ServiceInterface + BizUI + Impl
5.1 业务接口层(ModuleInterface)
swift
public protocol SearchEntryProtocol: AnyObject {
func makeSearchViewController() -> UIViewController
}
5.2 服务层接口(ServiceInterface)
swift
public struct SearchResult {
public let title: String
}
public protocol SearchServiceProtocol {
func search(keyword: String,
completion: @escaping (Result<[SearchResult], Error>) -> Void)
}
Online / Mock 两种实现
swift
public final class OnlineSearchService: SearchServiceProtocol {
private let network: NetworkClient
public init(network: NetworkClient) {
self.network = network
}
public func search(keyword: String,
completion: @escaping (Result<[SearchResult], Error>) -> Void) {
network.request(path: "/search", query: ["q": keyword]) { result in
switch result {
case .success(let strings):
completion(.success(strings.map { SearchResult(title: $0) }))
case .failure(let error):
completion(.failure(error))
}
}
}
}
public final class MockSearchService: SearchServiceProtocol {
public init() {}
public func search(keyword: String,
completion: @escaping (Result<[SearchResult], Error>) -> Void) {
let models = (1...5).map { SearchResult(title: "[MOCK] Result \($0) for \(keyword)") }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
completion(.success(models))
}
}
}
5.3 BizUI:只依赖 SearchServiceProtocol
swift
public final class SearchViewController: AWEBaseViewController {
private let service: SearchServiceProtocol
private let textField = UITextField()
private let button = UIButton(type: .system)
private let textView = UITextView()
private let iconView = UIImageView()
public init(service: SearchServiceProtocol) {
self.service = service
super.init(nibName: nil, bundle: nil)
self.title = "Search"
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
private func setupUI() {
textField.borderStyle = .roundedRect
textField.placeholder = "输入关键词,例如: douyin"
button.setTitle("搜索", for: .normal)
button.addTarget(self, action: #selector(onSearch), for: .touchUpInside)
textView.isEditable = false
textView.layer.borderColor = UIColor.lightGray.cgColor
textView.layer.borderWidth = 1
iconView.contentMode = .scaleAspectFit
iconView.heightAnchor.constraint(equalToConstant: 40).isActive = true
iconView.image = SearchResources.searchTabIcon
let stack = UIStackView(arrangedSubviews: [iconView, textField, button, textView])
stack.axis = .vertical
stack.spacing = 12
view.addSubview(stack)
stack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
textView.heightAnchor.constraint(equalToConstant: 250)
])
}
@objc private func onSearch() {
let keyword = textField.text ?? ""
guard !keyword.isEmpty else { return }
textView.text = "搜索中..."
service.search(keyword: keyword) { [weak self] result in
switch result {
case .success(let models):
self?.textView.text = models.map { $0.title }.joined(separator: "\n")
case .failure(let error):
self?.textView.text = "Error: \(error)"
}
}
}
}
5.4 Impl 层:按宿主选择 Service 实现
swift
public final class SearchEntryImpl: SearchEntryProtocol {
public init() {}
public func makeSearchViewController() -> UIViewController {
if let service = ServiceRegistry.shared.resolve(SearchServiceProtocol.self) {
return SearchViewController(service: service)
}
if let network = ServiceRegistry.shared.resolve(NetworkClient.self) {
return SearchViewController(service: OnlineSearchService(network: network))
}
let vc = UIViewController()
vc.view.backgroundColor = .systemBackground
vc.title = "Search (Service not available)"
return vc
}
}
public final class SearchServiceRegisterTask: BootTask {
private let useMock: Bool
public init(useMock: Bool) {
self.useMock = useMock
super.init(phase: .infra, priority: 60)
}
public override func execute(completion: @escaping () -> Void) {
if useMock {
let service = MockSearchService()
ServiceRegistry.shared.register(SearchServiceProtocol.self, impl: service)
} else if let network = ServiceRegistry.shared.resolve(NetworkClient.self) {
let service = OnlineSearchService(network: network)
ServiceRegistry.shared.register(SearchServiceProtocol.self, impl: service)
}
completion()
}
}
public final class SearchBootTask: BootTask {
public override func execute(completion: @escaping () -> Void) {
let entry = SearchEntryImpl()
ServiceRegistry.shared.register(SearchEntryProtocol.self, impl: entry)
Router.shared.register("douyin://search") { _ in
entry.makeSearchViewController()
}
completion()
}
}
public func registerSearchBootTasks(useMockService: Bool = false) {
LaunchKit.register(task: SearchServiceRegisterTask(useMock: useMockService))
LaunchKit.register(task: SearchBootTask(phase: .bizRegister, priority: 50))
}
6. Feed 模块:FeedService 也做成接口 + 多实现
6.1 FeedServiceProtocol + Online / Mock 实现
swift
import Foundation
import AWESearch
import AWELaunchKit
import AWEInfra
public protocol FeedServiceProtocol {
func loadFeedItems() -> [String]
func searchEntry() -> SearchEntryProtocol?
}
public final class OnlineFeedService: FeedServiceProtocol {
private let userService: UserService?
public init(userService: UserService?) {
self.userService = userService
}
public func loadFeedItems() -> [String] {
let userName = userService?.userName ?? "Guest"
return (1...20).map { "Feed Item \($0) for \(userName)" }
}
public func searchEntry() -> SearchEntryProtocol? {
ServiceRegistry.shared.resolve(SearchEntryProtocol.self)
}
}
public final class MockFeedService: FeedServiceProtocol {
public init() {}
public func loadFeedItems() -> [String] {
(1...10).map { "[MOCK] Feed Item \($0)" }
}
public func searchEntry() -> SearchEntryProtocol? {
ServiceRegistry.shared.resolve(SearchEntryProtocol.self)
}
}
6.2 Feed BizUI:只依赖 FeedServiceProtocol
swift
public final class FeedViewController: AWEBaseViewController {
private let tableView = UITableView()
private let feedService: FeedServiceProtocol
private var items: [String] = []
public init(feedService: FeedServiceProtocol) {
self.feedService = feedService
super.init(nibName: nil, bundle: nil)
self.title = "Feed"
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func viewDidLoad() {
super.viewDidLoad()
setupTableView()
setupSearchButton()
loadData()
}
private func setupTableView() {
tableView.dataSource = self
view.addSubview(tableView)
tableView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
private func setupSearchButton() {
let searchButton = UIBarButtonItem(title: "Search",
style: .plain,
target: self,
action: #selector(onSearch))
navigationItem.rightBarButtonItem = searchButton
}
private func loadData() {
items = feedService.loadFeedItems()
tableView.reloadData()
}
@objc private func onSearch() {
if let nav = navigationController {
Router.shared.open("douyin://search", from: nav)
return
}
if let entry = feedService.searchEntry(),
let nav = navigationController {
let vc = entry.makeSearchViewController()
nav.pushViewController(vc, animated: true)
}
}
}
extension FeedViewController: UITableViewDataSource {
public func tableView(_ tableView: UITableView,
numberOfRowsInSection section: Int) -> Int {
items.count
}
public func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cellId = "cell"
let cell = tableView.dequeueReusableCell(withIdentifier: cellId)
?? UITableViewCell(style: .default, reuseIdentifier: cellId)
cell.textLabel?.text = items[indexPath.row]
if let like = FeedResources.likeIcon {
cell.accessoryView = UIImageView(image: like)
}
return cell
}
}
6.3 Feed Impl:BootTask 注册 Online / Mock 实现
swift
public final class FeedEntryImpl: FeedEntryProtocol {
public init() {}
public func makeRootViewController() -> UIViewController {
if let service = ServiceRegistry.shared.resolve(FeedServiceProtocol.self) {
return FeedViewController(feedService: service)
}
let userService = ServiceRegistry.shared.resolve(UserService.self)
let service = OnlineFeedService(userService: userService)
return FeedViewController(feedService: service)
}
}
public final class FeedServiceRegisterTask: BootTask {
private let useMock: Bool
public init(useMock: Bool) {
self.useMock = useMock
super.init(phase: .infra, priority: 55)
}
public override func execute(completion: @escaping () -> Void) {
if useMock {
let service = MockFeedService()
ServiceRegistry.shared.register(FeedServiceProtocol.self, impl: service)
} else {
let userService = ServiceRegistry.shared.resolve(UserService.self)
let service = OnlineFeedService(userService: userService)
ServiceRegistry.shared.register(FeedServiceProtocol.self, impl: service)
}
completion()
}
}
public final class FeedBootTask: BootTask {
public override func execute(completion: @escaping () -> Void) {
let entry = FeedEntryImpl()
ServiceRegistry.shared.register(FeedEntryProtocol.self, impl: entry)
Router.shared.register("douyin://feed") { _ in
entry.makeRootViewController()
}
completion()
}
}
public func registerFeedBootTasks(useMockService: Bool = false) {
LaunchKit.register(task: FeedServiceRegisterTask(useMock: useMockService))
LaunchKit.register(task: FeedBootTask(phase: .bizRegister, priority: 40))
}
7. 子壳工程:SearchShell / FeedShell
7.1 SearchShell:专注搜索业务
swift
@main
class SearchShellAppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private func setupBootTasks() {
registerUIThemeBootTasks()
registerInfraBootTasks()
registerSearchBootTasks(useMockService: true) // 子壳用 MockSearchService
}
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
setupBootTasks()
LaunchKit.run(phase: .infra) {
LaunchKit.run(phase: .bizRegister) {
self.setupRootUI()
}
}
return true
}
private func setupRootUI() {
guard let entry = ServiceRegistry.shared.resolve(SearchEntryProtocol.self) else { return }
let nav = AWENavigationController(rootViewController: entry.makeSearchViewController())
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = nav
window.makeKeyAndVisible()
self.window = window
}
}
7.2 FeedShell:专注 Feed 业务
swift
@main
class FeedShellAppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private func setupBootTasks() {
registerUIThemeBootTasks()
registerInfraBootTasks()
registerSearchBootTasks(useMockService: true)
registerFeedBootTasks(useMockService: true) // 子壳用 MockFeedService
}
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
setupBootTasks()
LaunchKit.run(phase: .infra) {
LaunchKit.run(phase: .bizRegister) {
self.setupRootUI()
}
}
return true
}
private func setupRootUI() {
guard let entry = ServiceRegistry.shared.resolve(FeedEntryProtocol.self) else { return }
let nav = AWENavigationController(rootViewController: entry.makeRootViewController())
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = nav
window.makeKeyAndVisible()
self.window = window
}
}
8. 宿主 App:编排所有业务和中台
swift
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private func setupBootTasks() {
registerUIThemeBootTasks()
registerInfraBootTasks()
registerSearchBootTasks(useMockService: false) // 宿主用线上实现
registerFeedBootTasks(useMockService: false)
}
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
setupBootTasks()
LaunchKit.run(phase: .infra) {
LaunchKit.run(phase: .bizRegister) {
self.setupRootUI()
}
}
return true
}
private func setupRootUI() {
guard let entry = ServiceRegistry.shared.resolve(FeedEntryProtocol.self) else { return }
let root = entry.makeRootViewController()
let nav = AWENavigationController(rootViewController: root)
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = nav
window.makeKeyAndVisible()
self.window = window
}
}
9. 总结:这套 Demo 可以给你什么参考?
-
架构层面
- 单仓多组件(subspec 拆层)
- 业务接口层(ModuleInterface)
- 服务层接口(ServiceInterface),支持 Online / Mock 多实现
- 中台层:Infra + UIResources + UITheme
-
工程组织层面
- Podfile 只依赖业务的 Impl 层,宿主不直接碰内部层级
- 业务仓内部通过 subspec 定义 Basic / Model / Service / BizUI / Impl 分层
- ServiceRegistry 作为轻量级服务总线
-
研发流程层面
- 子壳工程可独立运行某一业务线
- 通过 useMockService 切换实现,支持离线、Mock、专项调试
- 后续可以很自然地加上 lint 规则、防止分层依赖被破坏
你可以把这套 Demo 当成一个「骨架」:
- 换成你们自己的业务:把 Feed / Search 换成 Home / Profile / Live 等;
- 把 StubNetworkClient / MockService 替换成真实后端;
- 在 ServiceInterface 上加更多能力(埋点、AB、灰度等);
- 加上静态分析脚本,限制谁可以依赖谁。
项目地址 :GITHUB