# AppFoundation **Repository Path**: Cdiam/app-foundation ## Basic Information - **Project Name**: AppFoundation - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-08-24 - **Last Updated**: 2026-09-01 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # AppFoundation 一个与具体业务无关的 UIKit 应用基础层 Swift Package。它从原 `health` 工程中抽离了可跨项目复用的能力,模块名保持通用,后续可以直接作为本地依赖或 Git 版本依赖使用。 ## 组件文档 - [AppFoundationUI 使用手册](Documentation/AppFoundationUI.md) ## 已抽离能力 - `APIClient`、`APIEndpoint`、`HTTPMethod`、`APIError`:基于 `async/await` 的基础网络请求层 - `UserDefaultsStore`、`KeychainStore`:基于 `Codable` 的本地与安全存储封装 - `AppLogger`:`OSLog` 日志入口 - `AppBootstrapper`:将应用启动拆成可排序、可失败的启动任务 - `NetworkMonitor`:基于 `NWPathMonitor` 的网络状态监听 - `ThemeStore`:基于主题协议的统一换肤入口和变化通知 - `PaginationController`:统一处理刷新、分页、加载状态和是否还有更多 - `ListHelper`:基于 UIKit 原生 `UIRefreshControl` 的下拉刷新、上拉加载、空状态和加载状态管理 - `BaseViewController`:`setupUI` / `setupLayout` / `bind` 页面生命周期约定 - `BaseCollectionViewController`:基于 Diffable Data Source 和 Compositional Layout 的列表基类 - `VerticalSpaceTransitionContainerViewController`:两个全屏页面的同层级纵向空间切换,包含可调阻尼、Spring 吸附和手势隔离 - `VerticalSpaceTransitionPhysics`:集中管理纵向空间切换的阻尼与弹簧参数 - `AppFoundationUI`:基于 TangramKit 的声明式容器、UIKit 链式配置、文本排版与富文本、闭包事件与手势 - `AppFoundationUIKingfisher`:可选的 Kingfisher 图片加载适配 - SnapKit 布局扩展 ## 明确不放入包内的内容 HealthKit、用户模型、用户接口、商城/发现/个人中心页面、应用启动流程和图片资源仍属于业务或项目级内容,继续留在业务工程中。SnapKit、Kingfisher、TangramKit 由对应产品管理,业务 target 可以按需选择 `AppFoundation`、`AppFoundationUI` 和 `AppFoundationUIKingfisher`。 ## 从旧基础工程保留并现代化的设计 旧工程里有几类值得保留的组织方式,但没有直接迁移 Objective-C 实现: - AppDelegate 分类拆分成 `AppConfiguration`、`AppService`、`AppLifeCircle`,现在对应为 `AppBootstrapTask`,由 `AppBootstrapper` 按顺序执行。 - `QDThemeManager` 的主题协议和主题切换通知保留为 `AppTheme`、`ThemeStore`,但不再依赖主题类名写入配置文件。 - `AFNetworkReachabilityManager` 的网络状态集中监听改为系统 `NWPathMonitor`,不再依赖旧的 AFNetworking 网络监测 API。 - `JSBaseDataService` 的刷新与分页意图保留为 `PaginationController`,但改成类型安全的 `async/await`,不绑定接口返回字段、字典参数或控制器代理。 - `JSListHelper` 的刷新和空页面意图保留为 `ListHelper`,使用系统 `UIRefreshControl` 和轻量 footer,不再绑定 MJRefresh、KafkaRefresh、DZNEmptyDataSet 或 YTKNetwork。 旧工程中的全局宏、用户单例、明文密码持久化、关闭 HTTPS 证书校验、依赖运行时私有类名的 TabBar 动画,以及把请求、登录、数据库和页面提示混在一起的服务对象,不作为新包能力继续保留。 ## 纵向空间切换组件 `VerticalSpaceTransitionContainerViewController` 用于承载两个同层级的全屏页面:Home 位于上方,Feed 位于 Home 正下方。Home → Feed 过程中,组件只移动内部页面容器的 `transform`,并使用非线性阻尼和 `UISpringTimingParameters` 完成吸附。 组件不创建业务页面,也不依赖 `UICollectionView`。宿主工程负责提供两个 `UIViewController`: - Home 页面负责自己的 UI 和内容 - Feed 页面负责自己的 `UICollectionView`、全屏分页和关闭按钮 - AppFoundation 负责 containment、Home → Feed 阻尼、松手判定、Spring 动画和手势隔离 ### 最小接入 ```swift import UIKit import AppFoundation @MainActor final class SentenceRootViewController: VerticalSpaceTransitionContainerViewController { init() { let feedViewController = SentenceFeedViewController() super.init( homeViewController: SentenceHomeViewController(), feedViewController: feedViewController, physics: VerticalSpaceTransitionPhysics( resistance: 0.52, threshold: 0.22, velocityThreshold: 900, minimumFlickProgress: 0.08, dampingRatio: 0.88, response: 0.46 ) ) feedViewController.onClose = { [weak self] in self?.closeFeed(animated: true) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } ``` `SentenceHomeViewController` 和 `SentenceFeedViewController` 是宿主工程中的业务页面名称示例,不属于 AppFoundation。 Scene-based App 在 `SceneDelegate` 中启动容器: ```swift let window = UIWindow(windowScene: windowScene) window.rootViewController = SentenceRootViewController() self.window = window window.makeKeyAndVisible() ``` ### Feed 关闭按钮 Feed 页面可以通过一个闭包把关闭事件交给宿主容器: ```swift final class SentenceFeedViewController: UIViewController { var onClose: (() -> Void)? @objc private func closeButtonTapped() { onClose?() } } ``` 调用 `closeFeed(animated: true)` 会使用约 300ms 的平滑动画返回 Home,不使用 Home → Feed 的阻尼规则。也可以使用 `closeFeed(animated: false)` 立即返回。 ### 物理参数 所有影响手感的参数都集中在 `VerticalSpaceTransitionPhysics`: ```swift var physics = VerticalSpaceTransitionPhysics() physics.resistance = 0.52 physics.threshold = 0.22 physics.velocityThreshold = 900 physics.dampingRatio = 0.88 physics.response = 0.46 ``` - `resistance`:越大,页面越滞后于手指 - `threshold`:有效拖动进度达到该比例后进入 Feed - `velocityThreshold`:快速向上 flick 的速度门槛 - `dampingRatio`:越大,Spring 越克制 - `response`:Spring 响应时间,越小越快 `resistedDistance(_:pageHeight:)` 只在 Home → Feed 的拖动阶段使用。进入 Feed 后,组件不会再修改 Feed 内部滚动行为。 ### 手势隔离约定 组件内部的 Home boundary pan 只在 `.home` 状态启用。Feed 进入完成后,Home pan 会关闭,并把 Feed ViewController 的交互打开;返回 Home 后顺序反过来。Feed 自己的 `UICollectionView` 可以正常使用 `isPagingEnabled = true` 做全屏上下分页,两个手势不会同时驱动页面。 启动任务示例: ```swift import AppFoundation struct AppearanceTask: AppBootstrapTask { let identifier = "appearance" func run() async throws { // 配置 UIKit appearance 或业务主题 } } let bootstrapper = AppBootstrapper(tasks: [AppearanceTask()]) try await bootstrapper.start() ``` 分页示例: ```swift let loader = PaginationController(pageSize: 20) { page, pageSize in let response: PageResponse = try await loadPage(page, pageSize) return PageResult( page: page, items: response.items, hasMore: response.hasMore ) } try await loader.refresh() try await loader.loadMore() ``` 列表刷新示例: ```swift final class FeedViewController: BaseViewController { private let tableView = UITableView(frame: .zero, style: .plain) private let pagination = PaginationController(pageSize: 20) { page, pageSize in let response = try await loadPage(page, pageSize) return PageResult(page: page, items: response.items, hasMore: response.hasMore) } override func setupUI() { super.setupUI() view.addSubview(tableView) configureList( on: tableView, refresh: { [weak self] in guard let self else { return } try await self.pagination.refresh() self.setListEmptyStateVisible(self.pagination.items.isEmpty) self.tableView.reloadData() }, loadMore: { [weak self] in guard let self else { return false } try await self.pagination.loadMore() self.tableView.reloadData() return self.pagination.hasMore }, emptyState: ListEmptyStateConfiguration( title: "No content", actionTitle: "Retry" ) ).emptyStateView.onAction = { [weak self] in self?.listHelper?.beginRefreshing() } } override func setupLayout() { 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) ]) } override func scrollViewDidScroll(_ scrollView: UIScrollView) { super.scrollViewDidScroll(scrollView) } } ``` `BaseViewController` 会把滚动事件转给 `ListHelper`,从而触发加载更多。子类如果自己实现 `scrollViewDidScroll`,需要调用 `super`。 ## 传统列表快速配置 `BaseViewController` 不创建列表 View。业务页面负责创建列表、添加到层级并设置约束,`configureTableList` 和 `configureCollectionList` 只负责注册 Cell、接管 DataSource/Delegate、配置 Cell 和转发点击事件。数据变化后调用返回的适配器的 `reloadData()`,或直接调用列表的 `reloadData()`。 ### UITableView ```swift final class FeedViewController: BaseViewController { private let tableView = UITableView(frame: .zero, style: .plain) private var items: [FeedItem] = [] override func setupUI() { super.setupUI() view.addSubview(tableView) configureTableList( tableView, cellType: FeedCell.self, numberOfRows: { [weak self] _ in self?.items.count ?? 0 }, configureCell: { [weak self] cell, indexPath in guard let item = self?.items[indexPath.row] else { return } cell.configure(with: item) }, didSelectCell: { [weak self] _, indexPath in self?.openItem(at: indexPath.row) }, rowHeight: { _ in 72 } ) } override func setupLayout() { tableView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) } } ``` 如果需要刷新和分页,把 `refresh` / `loadMore` 传给同一个方法即可。基类会使用 UIKit 原生 `UIRefreshControl` 和轻量 footer: ```swift configureTableList( tableView, cellType: FeedCell.self, numberOfRows: { [weak self] _ in self?.items.count ?? 0 }, configureCell: { [weak self] cell, indexPath in guard let item = self?.items[indexPath.row] else { return } cell.configure(with: item) }, refresh: { [weak self] in guard let self else { return } self.items = try await loadItems() self.tableView.reloadData() }, loadMore: { [weak self] in guard let self else { return false } let nextItems = try await loadNextItems() self.items.append(contentsOf: nextItems.items) self.tableView.reloadData() return nextItems.hasMore } ) ``` ### UICollectionView(Flow Layout) 这是传统 `UICollectionViewDataSource` / `UICollectionViewDelegateFlowLayout` 的使用方式。页面自己创建 `UICollectionViewFlowLayout` 和 CollectionView,适配器隐藏注册、取 Cell、数量、尺寸和点击事件的固定代码: ```swift final class GalleryViewController: BaseViewController { private let collectionView: UICollectionView = { let layout = UICollectionViewFlowLayout() layout.scrollDirection = .vertical layout.minimumLineSpacing = 12 layout.minimumInteritemSpacing = 8 let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.backgroundColor = .systemBackground return collectionView }() private var items: [GalleryItem] = [] override func setupUI() { super.setupUI() view.addSubview(collectionView) configureCollectionList( collectionView, cellType: GalleryCell.self, numberOfItems: { [weak self] _ in self?.items.count ?? 0 }, configureCell: { [weak self] cell, indexPath in guard let item = self?.items[indexPath.item] else { return } cell.configure(with: item) }, didSelectCell: { [weak self] _, indexPath in self?.openItem(at: indexPath.item) }, itemSize: { _, availableSize in let spacing: CGFloat = 8 let side = (availableSize.width - 32 - spacing) / 2 return CGSize(width: side, height: side) }, sectionInset: { _ in UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) } ) } override func setupLayout() { collectionView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) } } ``` `configureCollectionList` 还支持 `minimumLineSpacing` 和 `minimumInteritemSpacing` 按 Section 配置;不传时沿用 `UICollectionViewFlowLayout` 自身的设置。需要 Compositional Layout、Diffable Data Source 或更复杂的 Supplementary View 时,继续使用 `BaseCollectionViewController`,不要在同一个 CollectionView 上同时配置两套 DataSource / Delegate。 两个适配器都会被 `BaseViewController` 持有,因此不会因为 UIKit 的弱代理引用而提前释放。子类如果重写 `scrollViewDidScroll(_:)`,需要调用 `super`,这样上拉加载仍会正常触发。 ## 本地接入 在其他 Xcode 工程的 `Package.swift` 中添加: ```swift dependencies: [ .package(path: "../app-foundation-spm") ] ``` 并在 target 中添加产品: ```swift dependencies: [ .product(name: "AppFoundation", package: "AppFoundation") ] ``` 然后即可使用: ```swift import AppFoundation final class HomeViewController: BaseViewController { override func setupUI() { super.setupUI() title = "Home" } } ``` ## 网络请求示例 业务工程自行声明 Endpoint,包只负责通用请求流程: ```swift import AppFoundation import Foundation struct ProfileEndpoint: APIEndpoint { let path = "/user/profile" let method: HTTPMethod = .get } let client = APIClient(baseURL: URL(string: "https://api.example.com")!) let profile: Profile = try await client.request(ProfileEndpoint()) ``` POST 请求可以通过 `headers` 和 `body` 提供业务参数: ```swift struct LoginEndpoint: APIEndpoint { let path = "/user/login" let method: HTTPMethod = .post let body: Data? } ``` ## 远程 SPM 接入 将本目录推送到 Git 仓库并打版本 Tag 后,其他项目可改为: ```swift .package(url: "", from: "1.0.0") ``` 当前包统一依赖 SnapKit 6.0.0、Kingfisher 8.11.0 和 TangramKit `master` 分支,最低支持 iOS 16.0。TangramKit 继续沿用原工程的 `master` 分支配置。 ## 验证 这个包的目标平台是 iOS,且包含 UIKit,因此应在 Xcode 的 iOS Simulator 或真机目标下编译和运行测试。`swift package describe` 可用于检查包清单;仅在 macOS 主机上直接执行 `swift build` 会因为没有 UIKit 而失败。 ## Xcode 工程接入流程 对于现有的 `.xcodeproj` 或 `.xcworkspace` 工程: 1. 打开 Xcode,选择 `File` → `Add Package Dependencies...`。 2. 选择 `Add Local...`。 3. 选择本地目录: ```text /Users/shenjie/Documents/project/app-foundation-spm ``` 4. 根据业务需要,将 `AppFoundation`、`AppFoundationUI`、`AppFoundationUIKingfisher` 中的一个或多个产品添加到 App Target。 5. 在业务代码中按需导入: ```swift import AppFoundation import AppFoundationUI import AppFoundationUIKingfisher ``` 业务代码如果直接使用三方库类型,也可以导入: ```swift import SnapKit import Kingfisher import TangramKit ``` ## 常用能力示例 ### UIKit 链式配置 ```swift import AppFoundationUI let titleLabel = UILabel() .text("标题") .font(.boldSystemFont(ofSize: 18)) .textColor(.label) .lines(1) ``` ### Kingfisher 图片加载 ```swift import AppFoundationUI import AppFoundationUIKingfisher let avatarView = UIImageView() .contentMode(.scaleAspectFill) .cornerRadius(24) .imageURL( "https://example.com/avatar.png", placeholder: UIImage(named: "avatar_placeholder") ) avatarView.cancelImageDownload() ``` ### TangramKit 布局 ```swift import AppFoundationUI let layout = TGLayout.vertical(spacing: 12) { titleLabel avatarView } view.addSubview(layout) ``` ### 存储 ```swift let defaults = UserDefaultsStore() try defaults.set(true, forKey: "hasShownGuide") let hasShownGuide: Bool? = try defaults.value( forKey: "hasShownGuide" ) let keychain = KeychainStore(service: "com.example.app.auth") try keychain.save("token-value", forKey: "accessToken") let token: String? = try keychain.value(forKey: "accessToken") ``` ### 网络状态 ```swift let networkMonitor = NetworkMonitor() networkMonitor.onStatusChange = { status in print("网络状态:\(status)") } networkMonitor.start() // 不再需要时调用 networkMonitor.stop() ``` ### 主题 ```swift @MainActor final class LightTheme: AppTheme { let identifier = "light" let tintColor = UIColor.systemBlue let backgroundColor = UIColor.systemBackground let labelColor = UIColor.label func applyAppearance() { UINavigationBar.appearance().tintColor = tintColor } } ThemeStore.shared.setTheme(LightTheme()) ``` 主题变化通知: ```swift NotificationCenter.default.addObserver( forName: .appThemeDidChange, object: ThemeStore.shared, queue: .main ) { _ in // 刷新页面主题 } ``` ## 设计边界 基础包负责通用机制,业务项目负责业务内容: - API Endpoint 和 Codable 模型 - 登录流程、用户模型和权限逻辑 - 页面、路由和 TabBar 配置 - 具体主题颜色、业务样式和本地化文案 - 埋点、推送、崩溃平台等第三方初始化参数 不建议把业务单例、明文密码、接口状态码约定、页面文案或具体业务模型继续放入基础包。 ## 目录结构 ```text Sources/AppFoundation/ ├── Application/ 启动任务、网络状态 ├── Configuration/ 主题、分页 ├── Logging/ OSLog 日志 ├── Network/ async/await 网络层 ├── Storage/ UserDefaults、Keychain └── UI/ 页面基类、列表与页面切换组件 Sources/AppFoundationUI/ ├── Core/ 构建器、控件工厂、configure ├── Layout/ TangramKit 声明式容器和滚动容器 ├── Modifiers/ UIKit 链式修饰 ├── Interaction/ UIControl 事件和手势 └── Style/ 颜色与按钮样式预设 Sources/AppFoundationUIKingfisher/ └── UIImageView+Kingfisher.swift ```