# 应用启动流程与接口数据存储梳理 本文按当前 iOS 工程代码梳理应用从冷启动到各首屏展示期间发生的初始化、接口调用顺序,以及接口数据和本地状态的存储方式。范围仅覆盖当前 iOS 实现,不额外补充 Android 中存在但 iOS 尚未接入的能力。 ## 1. 冷启动立即发生 冷启动入口在 `AppDelegate.application(_:didFinishLaunchingWithOptions:)`。 1. 调用 `UmengBootstrap.configureIfNeeded()`。 - 友盟初始化受 `AppStore.shared.privacyAgreementAccepted` 控制。 - 未同意隐私协议时直接返回,不初始化 SDK。 2. 调用 `WeChatManager.shared.registerIfNeeded()`。 - 校验 Universal Link。 - 调用微信 SDK `WXApi.registerApp` 注册 AppID。 3. 若 `AppStore.shared.privacyAgreementAccepted == true` 且 `AppStore.shared.token` 非空,则调用 `AMapBootstrap.configureIfNeeded()`。 - 写入高德隐私合规状态。 - 设置 `AMapServices.shared().apiKey` 和 HTTPS。 4. 此阶段不直接调用任何业务后端接口。 随后系统进入 `SceneDelegate.scene(_:willConnectTo:options:)`。 1. 调用 `AppNavigationBarAppearance.applyGlobalAppearance()` 设置全局导航栏样式。 2. 创建 `UIWindow`。 3. 设置 `window.rootViewController = AppRouter.makeRootViewController()`。 4. `AppRouter` 通过 `AppStore.shared.isLoggedIn` 判断根页面: - token 非空:进入 `MainTabBarController`。 - token 为空:进入 `UINavigationController(rootViewController: LoginViewController())`。 5. 注册全局通知: - `sessionDidExpire`:清除登录态并切回登录页。 - `userDidLogout`:清除登录态并切回登录页。 - `userDidLogin`:切到主 Tab。 ## 2. 网络依赖初始化 `NetworkServices.shared` 是全局网络依赖容器,不在 `AppDelegate` 中主动创建,而是在页面或 Tab 首次访问业务 API 时懒加载。 初始化顺序如下: 1. 创建 `APIClient(environment: .current)`。 2. 用同一个 `APIClient` 创建各业务 API: - `AuthAPI` - `ProfileAPI` - `StatisticsAPI` - `OrderAPI` - `HomeAPI` - 以及其他业务模块 API。 3. 创建 `OSSUploadService(configService: uploadAPI)`。 4. 调用 `client.bindAuthTokenProvider`,从 `AppStore.shared.token` 读取 token。 `APIClient` 的公共请求规则: - `APIEnvironment.current` - Debug:`https://api-test.zhifly.cn` - Release:`https://api.zhifly.cn` - 每个请求默认写入 Header: - `Content-Type: application/json` - `Accept: application/json` - `X-APP-VERSION` - `X-OS-TYPE: iOS` - token 非空时写入 `token` - 响应通过 `APIEnvelope` 解包。 - 成功业务码:`100000` - 非成功业务码抛出 `APIError.serverCode` - 认证失效时广播 `sessionDidExpire`,由 `SceneDelegate` 清会话并回登录页。 ## 3. 未登录启动与登录流程 未登录时根页面为登录页。 ### 3.1 登录页展示前 `LoginViewController.setupUI` 会调用 `viewModel.applyStoredPreferences()`,从 `AppStore` 读取: - `lastLoginUsername` - `privacyAgreementAccepted` 这一步只恢复本地表单状态,不调用后端接口。 ### 3.2 登录页出现后 `LoginViewController.viewDidAppear` 调用: ```text GET /api/app/config ``` 调用链: ```text LoginViewController.viewDidAppear -> LoginViewModel.loadAppConfig(authAPI:) -> AuthAPI.getAppConfig() ``` 用途: - 读取 App 配置。 - 当前只使用 `enableRegister` 控制验证码登录/注册入口显隐。 - 配置加载失败会静默忽略,不影响密码登录。 - 该结果只保存在 `LoginViewModel.enableRegister` 内存态,不写入 `AppStore`。 ### 3.3 点击登录后的接口顺序 用户点击登录按钮后,先做本地校验: - 手机号必须是 11 位且以 `1` 开头。 - 密码不能为空。 - 必须勾选隐私协议。 校验通过后发起登录: ```text POST /api/app/v9/login ``` 调用链: ```text LoginViewController.performLogin() -> LoginViewModel.login(authAPI:) -> AuthAPI.login(username:password:) ``` 返回数据包含临时 token 和可选账号列表。随后根据账号数量分流: - 只有一个账号:立即调用 `set-user` 换正式 token。 - 多个账号:展示账号选择弹窗,用户确认后再调用 `set-user`。 正式账号选择接口: ```text POST /api/app/v9/set-user ``` 调用链: ```text 单账号: LoginViewModel.resolveLoginResponse(...) -> AuthAPI.setUser(..., tokenOverride: 临时 token) 多账号: LoginViewController.selectAccount(_:) -> LoginViewModel.selectAccount(_:authAPI:) -> AuthAPI.setUser(..., tokenOverride: 临时 token) ``` ### 3.4 登录完成后的存储与跳转 登录完成调用: ```text AuthSessionHelper.completeLogin(...) ``` 写入 `AppStore` 的内容: - `token` - `lastLoginUsername` - `privacyAgreementAccepted` - `userId` - `userName` - `realName` - `avatar` - `phone` - `accountType` - `accountDisplayName` - `roleCode` - `roleName` - `currentScenicId` - `currentScenicName` - `currentStoreId` 其中账号上下文来自 `AccountSwitchAccount`、`V9ScenicUser` 或 `V9StoreUser`。 如果用户已同意隐私协议,还会再次触发: - `AMapBootstrap.configureIfNeeded()` - `UmengBootstrap.configureIfNeeded()` 最后发送: ```text NotificationName.userDidLogin ``` `SceneDelegate.handleUserDidLogin()` 收到通知后调用: ```text AppRouter.setRoot(.mainTab, on: window) ``` 根页面切换到主 Tab。 ## 4. 已登录启动与默认首页首次展示 已登录时,`AppRouter.makeRootViewController()` 创建 `MainTabBarController`。 ### 4.1 主 Tab 创建 `MainTabBarController.viewDidLoad` 顺序: 1. 设置 `delegate`。 2. 配置 TabBar 外观。 3. 调用 `configureTabs()` 创建五个 Tab slot: - 首页:`HomeViewController` - 订单:`OrdersViewController` - 中间扫码占位页 - 数据:`StatisticsViewController` - 我的:`ProfileViewController` 4. 绑定订单角标 ViewModel。 5. 绑定扫码处理器。 6. 调用 `badgeViewModel.refreshPendingWriteOffCount()`。 注意:`MainTabBadgeViewModel.refreshPendingWriteOffCount()` 当前仅执行: ```text pendingWriteOffCount = nil ``` 代码注释标明“接口未接入前保持为空”,因此这里不记录为真实后端接口调用。 ### 4.2 默认首页首次出现 默认选中首页。`HomeViewController.viewDidAppear` 首次出现时调用: ```text initializeHome() ``` 调用顺序: 1. 展示全局 loading。 2. `HomeViewModel.initialize(api:)` 3. 隐藏 loading。 4. 刷新首页 UI。 5. 延迟评估弹窗。 `HomeViewModel.initialize(api:)` 内部顺序: 1. `locationStateStore.restoreStateIfNeeded()` - 从 `AppStore` 恢复在线状态、上次位置上报时间、提醒分钟数。 - 如果上次在线状态已过期,会清除在线状态和上次上报时间。 - 不调用后端接口。 2. `refreshLocalDisplayState()` - 从 `AppStore` 读取当前景区、当前角色。 - 无有效景区时清空 `currentScenicId/currentScenicName`。 3. 强制调用 `loadPermissions(api:force: true)`。 权限接口: ```text GET /api/yf-handset-app/role-permission ``` 调用链: ```text HomeViewModel.initialize(api:) -> HomeViewModel.loadPermissions(api:force: true) -> HomeAPI.rolePermissions() ``` 权限返回后的存储: - 完整角色权限列表:`AppStore.saveRolePermissionList(_:)` - 当前匹配角色的景区列表:`AppStore.saveRoleScenicList(_:)` - 当前匹配角色的扁平权限:`AppStore.savePermissionItems(_:)` - 匹配角色名:必要时写入 `AppStore.roleName` 这些权限数据都按账号作用域 key 写入 `UserDefaults`,值为 JSON Data。 随后首页重建常用应用: - 从 `AppStore.permissionItems()` 读取权限。 - `HomeMenuCatalog.visibleMenus(from:)` 生成可见菜单。 - `HomeCommonMenuStore` 读取或写入常用应用 URI。 - 常用应用 key 由 `accountScope + roleCode` 组成。 ### 4.3 首页门店接口 权限加载完成后会调用: ```text HomeViewModel.loadStoreListIfNeeded(api:) ``` 只有同时满足以下条件才会请求门店: - 当前角色为 `storeAdmin` - `AppStore.currentScenicId > 0` 接口: ```text GET /api/app/store/all ``` 调用链: ```text HomeViewModel.loadStoreListIfNeeded(api:) -> HomeAPI.storeList() ``` 返回后的处理: - 根据 `currentScenicId` 和已保存的 `currentStoreId` 匹配当前门店。 - 匹配结果保存在 `HomeViewModel.storeItem`。 - 若匹配到门店,写入 `AppStore.currentStoreId`。 - 门店列表本身不持久化。 ### 4.4 首页弹窗评估与位置详情接口 首页 UI 刷新后调用: ```text evaluateDialogsWithDelay() -> HomeViewModel.evaluateDialogs(api:) ``` 弹窗优先级: 1. 权限弹窗。 2. 景区弹窗。 3. 位置超时弹窗。 如果不需要权限弹窗、不需要景区弹窗、不是简化顶部角色,并且本地设置了位置提醒分钟数,则可能调用位置详情接口: ```text GET /api/yf-handset-app/photog/loacation/detail?staff_id=... ``` 调用链: ```text HomeViewModel.evaluateDialogs(api:) -> HomeViewModel.checkLocationTimeout(api:) -> HomeAPI.locationDetail(staffId:) ``` 返回后的处理: - 只用于判断是否展示位置超时提醒。 - 结果不写入 `AppStore`。 - 弹窗状态保存在 `HomeViewModel.showLocationTimeoutDialog` 等内存属性中。 ## 5. 其他 Tab 首次展示时的接口 其他 Tab 的根控制器会在 `MainTabBarController.configureTabs()` 时创建,但各自的数据接口通常在首次展示时才调用。 ### 5.1 订单 Tab `OrdersViewController.viewDidAppear` 首次出现时调用: ```text initializeList() ``` 按当前角色选择接口。 摄影师: ```text GET /api/yf-handset-app/photog/order/listv2 ``` 景区管理员: ```text GET /api/app/scenic-admin/order/list ``` 门店管理员: ```text GET /api/app/store/order/list ``` 调用链: ```text OrdersViewController.initializeList() -> OrderListViewModel.refreshOrderList(api:) -> OrderAPI.photographerOrderList(...) 或 OrderAPI.scenicAdminOrderList(...) 或 OrdersViewController.initializeList() -> DepositOrderListViewModel.refreshOrderList(api:) -> OrderAPI.storeOrderList(...) ``` 数据存储: - 摄影师/景区管理员订单列表保存在 `OrderListViewModel.orderList`。 - 门店管理员订单列表保存在 `DepositOrderListViewModel.orderList`。 - 分页状态、筛选状态、搜索状态都保存在对应 ViewModel 内存属性中。 - 首屏订单列表不写入 `AppStore`。 ### 5.2 数据 Tab `StatisticsViewController.viewDidAppear` 首次出现时调用: ```text initializeStatistics() -> StatisticsViewModel.initStatistics(api:) ``` 内部顺序: 1. 加载汇总数据。 2. 设置日明细日期范围为今日并刷新列表。 摄影师汇总: ```text GET /api/yf-handset-app/photog/analyse/user ``` 摄影师日明细: ```text GET /api/yf-handset-app/photog/analyse/user/daily ``` 景区管理员汇总: ```text GET /api/app/scenic-admin/analyse ``` 景区管理员日明细: ```text GET /api/app/scenic-admin/analyse/daily ``` 门店管理员汇总: ```text GET /api/app/store/analyse ``` 门店管理员日明细: ```text GET /api/app/store/analyse/daily ``` 数据存储: - 汇总数据保存在 `StatisticsViewModel.statistics`。 - 日明细列表保存在 `StatisticsViewModel.statisticsList`。 - 当前周期、日期范围、分页状态保存在 `StatisticsViewModel` 内存属性中。 - 统计数据不写入 `AppStore`。 ### 5.3 我的 Tab `ProfileViewController.viewWillAppear` 每次出现都会调用: ```text reloadProfile(showGlobalLoading:) -> ProfileViewModel.reload(api:) ``` 第一步固定调用用户资料接口: ```text GET /api/yf-handset-app/userinfo ``` 调用链: ```text ProfileViewModel.reload(api:) -> ProfileAPI.userInfo() ``` 返回后的处理: - 保存在 `ProfileViewModel.userInfo`。 - 调用 `AppStore.applyUserInfo(_:)` 回写展示字段: - `userName` - `realName` - `avatar` - `phone` - `roleName`,仅在本地为空时由 `applyUserInfo` 写入;随后 `ProfileViewModel.reload` 也会在接口 `roleName` 非空时写入。 如果当前为摄影师角色,则并发调用: ```text GET /api/yf-handset-app/photog/real-name/info GET /api/yf-handset-app/photog/wallet/bank-card/info ``` 调用链: ```text async let realName = api.realNameInfo() async let bankCard = api.bankCardInfo() ``` 返回后的处理: - 实名信息保存在 `ProfileViewModel.realNameInfo`。 - 银行卡信息保存在 `ProfileViewModel.bankCardInfo`。 - 这两个结果不写入 `AppStore`。 ## 6. 数据存储总览 ### 6.1 AppStore / UserDefaults `AppStore` 是本地会话和业务快照的统一入口,底层使用 `UserDefaults`。 非账号作用域的主要 key: - `key_in_token` - `key_last_login_username` - `key_privacy_agreement_accepted` - `key_in_user_id` - `key_in_user_name` - `key_in_real_name` - `key_in_avatar` - `key_in_phone` - `key_in_account_type` - `key_in_account_display_name` - `key_in_role_code` - `key_in_role_name` - `key_in_current_scenic_id` - `key_in_current_scenic_name` - `key_in_current_store_id` 账号作用域由 `accountCachePrefix` 生成: ```text userId_accountType ``` 如果没有 userId,则使用 `guest`。账号作用域 key 用于存储: - 旧版角色 ID:`key_in_role_id` - 完整 role-permission 列表:`key_role_permission_list` - 当前角色扁平权限:`key_in_permission` - 当前角色可选景区列表:`key_current_role_scenic_list` - 在线状态:`key_online_status` - 上次位置上报时间:`key_last_location_report_time` - 位置提醒分钟数:`key_location_reminder_minutes` - 收款到账语音播报开关:`key_is_open_receive_voice` - 排队叫号相关本地设置。 权限和景区列表以 JSON Data 写入 `UserDefaults`: - `saveRolePermissionList(_:)` - `savePermissionItems(_:)` - `saveRoleScenicList(_:)` ### 6.2 HomeCommonMenuStore 首页常用应用由 `HomeCommonMenuStore` 单独管理,底层也是 `UserDefaults`。 存储 key: ```text {accountScope}_role_{roleCode}_common_uris ``` 值为 URI 字符串数组。 首次有权限但没有常用应用记录时,会从权限列表生成最多 4 个默认常用入口并写入本地。 ### 6.3 HomeLocationStateStore `HomeLocationStateStore` 负责首页在线状态和 2 小时位置上报倒计时。 持久化到 `AppStore` 的字段: - `onlineStatus` - `lastLocationReportTime` - `locationReminderMinutes` 内存态字段: - `isOnline` - `elapsedSeconds` - `nextReportCountdownSeconds` - `reminderMinutes` - 倒计时 `Task` 启动恢复逻辑: - 如果本地 `onlineStatus == false`,停止倒计时。 - 如果没有 `lastLocationReportTime`,清除在线状态。 - 如果距离上次上报已超过 2 小时,清除在线状态和上次上报时间。 - 否则恢复倒计时。 ### 6.4 ViewModel 内存态 多数页面接口结果只保存在 ViewModel 内存属性中,不持久化。 典型示例: - 登录配置:`LoginViewModel.enableRegister` - 首页门店卡片:`HomeViewModel.storeItem` - 首页弹窗状态:`HomeViewModel.showPermissionDialog`、`showScenicDialog`、`showLocationTimeoutDialog` - 订单列表:`OrderListViewModel.orderList`、`DepositOrderListViewModel.orderList` - 数据统计:`StatisticsViewModel.statistics`、`statisticsList` - 我的页面实名/银行卡状态:`ProfileViewModel.realNameInfo`、`bankCardInfo` 例外是明确调用 `AppStore` 的数据: - 登录 token 与账号上下文。 - 用户资料展示字段。 - 权限、景区、角色相关快照。 - 门店当前 ID。 - 位置在线状态与倒计时锚点。 ## 7. 启动接口顺序速查 ### 7.1 未登录冷启动 ```text AppDelegate.didFinishLaunching -> 友盟/微信/高德 SDK 条件初始化 -> SceneDelegate.willConnect -> AppRouter.makeRootViewController() -> LoginViewController.setupUI -> LoginViewController.viewDidAppear -> GET /api/app/config ``` 点击登录后: ```text POST /api/app/v9/login -> POST /api/app/v9/set-user -> AppStore 写入登录态和账号上下文 -> userDidLogin -> AppRouter.setRoot(.mainTab) ``` ### 7.2 已登录冷启动默认首页 ```text AppDelegate.didFinishLaunching -> 友盟/微信/高德 SDK 条件初始化 -> SceneDelegate.willConnect -> AppRouter.makeRootViewController() -> MainTabBarController.viewDidLoad -> HomeViewController.viewDidAppear -> HomeLocationStateStore.restoreStateIfNeeded() -> GET /api/yf-handset-app/role-permission -> 条件满足时 GET /api/app/store/all -> 条件满足时 GET /api/yf-handset-app/photog/loacation/detail?staff_id=... ``` ### 7.3 用户首次切到订单 Tab ```text OrdersViewController.viewDidAppear -> 摄影师 GET /api/yf-handset-app/photog/order/listv2 或 景区管理员 GET /api/app/scenic-admin/order/list 或 门店管理员 GET /api/app/store/order/list ``` ### 7.4 用户首次切到数据 Tab ```text StatisticsViewController.viewDidAppear -> 按角色 GET 汇总接口 -> 按角色 GET 日明细接口 ``` ### 7.5 用户切到我的 Tab ```text ProfileViewController.viewWillAppear -> GET /api/yf-handset-app/userinfo -> 摄影师角色并发: GET /api/yf-handset-app/photog/real-name/info GET /api/yf-handset-app/photog/wallet/bank-card/info ```