应用架构与页面导航
杰理健康(JieliJianKang)iOS 应用采用「AppDelegate 统一入口 + 根控制器动态切换 + UITabBarController 嵌套 UINavigationController」的经典 Objective-C 架构:启动时依次经历声明页、登录页,最终进入以 4 个 Tab 为主的健康主界面,并通过 JLApplicationDelegate.navigationController 提供全局页面导航能力。
Purpose and Scope
本页面向开发者说明杰理健康 App 的整体应用架构与页面导航机制,覆盖:
AppDelegate的启动流程与全局初始化(日志、崩溃上报、语言检测、网络监测、SDK 配置)- 根控制器(
rootViewController)的三种状态切换:声明页 → 登录页 → 主界面 - 主界面
UITabBarController+ 4 个UINavigationController的容器结构 BridgeHelper提供的全局导航访问入口与页面跳转实践
与页面导航相关的其他子系统(如 BLE 设备通信 JL_BLEKit、AI 云服务 AIClound、HTTP 客户端 BasicHttp/JLWatchHttp、自定义控件 JLSwiperView 等)各有独立的职责边界,属于兄弟主题页面,不在本页展开;本页仅在导航链路中提及它们被挂载的位置。
Overview
杰理健康是杰理科技(Jieli-Tech)出品的健康管理 iOS 应用,工程位于仓库 code/JL_Health/JieliJianKang 目录,全部界面层代码使用 Objective-C 编写,并集成了多个 SDK/框架:
| 集成项 | 用途 |
|---|---|
JL_BLEKit / JL_RunSDK | 蓝牙设备连接与运动数据 |
AIKIT.framework | AI 语音/对话能力(Audio、Chat) |
IFlyMSC(讯飞) | 语音识别(ISR)与合成(TTS) |
AMapFoundationKit(高德) | 地图/定位服务 |
JLLogHelper | 本地日志管理 |
Bugly | 崩溃监控上报 |
DFUnits | 杰理基础工具库 |
AFNetworking | 网络请求与可达性监测 |
应用启动时的核心设计意图是**“先声明、后登录、再进主界面”的分阶段根控制器切换**:把用户协议声明、账户登录、主功能界面分别建模为独立的根控制器状态,由 AppDelegate 统一裁决切换时机,避免业务页面直接处理合规与登录逻辑。
Architecture
下图展示了杰理健康 App 的整体分层架构与页面容器关系:
flowchart TD
subgraph sg_Launch["启动阶段 (AppDelegate)"]
A["application:didFinishLaunchingWithOptions:"]
A --> B["全局初始化<br/>日志/崩溃/语言/网络/SDK"]
A --> C{"切换根控制器"}
end
subgraph sg_Root["根控制器状态"]
D["JLStatementViewController<br/>用户声明页"]
E["LoginVC<br/>登录页"]
F["TabBarVC<br/>主界面 (UITabBarController)"]
end
subgraph sg_Main["主界面 Tab 结构"]
F --> G["nvc_1 → HealthVC<br/>健康页"]
F --> H["nvc_2 → SportVC<br/>运动页"]
F --> I["nvc_3 → DeviceSearchVC<br/>设备页"]
F --> J["nvc_4 → MyVC<br/>我的页"]
end
subgraph sg_Global["全局导航入口"]
K["BridgeHelper.getNavigationController"]
K --> L["main_nvc<br/>主 UINavigationController"]
end
C -->|"未同意声明"| D
D -->|"同意后回调 delegate"| C
C -->|"未登录"| E
E -->|"登录成功 delegate"| C
C -->|"已登录/已同意"| F
L -.-> F
架构说明:
- AppDelegate 作为唯一入口与状态机:
didFinishLaunchingWithOptions:完成全部全局初始化后,根据声明/登录状态把window.rootViewController分别设置为声明页、登录页或主界面(见 AppDelegate.m)。 - 声明页与登录页通过 delegate 回调回流:
AppDelegate同时实现了LoginDelegate与JLStatementViewControllerDelegate,子页面完成动作后回调 AppDelegate,由 AppDelegate 再次裁决下一个根控制器,形成闭环(见 AppDelegate.m)。 - 主界面 = TabBar 外套 NavigationController:4 个业务根页面各自包一层
UINavigationController成为 Tab,整个UITabBarController再被包进一个主UINavigationController(main_nvc),使 App 级页面(如声明、登录)可以整体压栈呈现(见 AppDelegate.m)。 - 全局导航通过 BridgeHelper 暴露:
BridgeHelper提供类方法返回JLApplicationDelegate.navigationController,任意模块(含 JavaScript Bridge)都能拿到主导航栈执行push/pop(见 BridgeHelper.m)。
启动流程详解
全局初始化(didFinishLaunchingWithOptions)
application:didFinishLaunchingWithOptions: 是 App 的启动入口,按顺序完成以下初始化工作:
- iOS 15 兼容:清除
UITableView的 section header 顶部内边距(setSectionHeaderTopPadding:0.0),适配新系统默认样式。 - 屏幕常亮:设置
idleTimerDisabled = YES,健康/运动场景下防止息屏。 - 强制左到右布局:
semanticContentAttribute = ForceLeftToRight,统一多语言环境下的排版方向。 - 日志系统:先
clearLog清空旧日志,再saveLogAsFile:true落盘,开启JLLOG_DEBUG级别与时间戳(见 AppDelegate.m)。 - 崩溃上报:
[Bugly startWithAppId:@"7a7c17c3ee"]启动腾讯 Bugly。 - 语言检测:读取系统语言
kJL_GET,按前缀映射到 App 支持的语言(en-GB、zh-Hans、ja、ko、fr、de、it、pt-PT、es、sv、pl、ru、tr、vi、he、th、ar、id、ms、fa),无法识别时回退为"auto"(见 AppDelegate.m)。 - 令牌刷新:
[[User_Http shareInstance] refreshAccessToken]预刷新登录令牌。 - 网络监测:启动
AFNetworkReachabilityManager可达性监听,供全局无网提示(NoNetView)使用。 - 高德 SDK:配置 AMap 的 apiKey。
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
if (@available(iOS 15.0, *)) {
[[UITableView appearance] setSectionHeaderTopPadding:0.0];
}
/*--- 设置屏幕常亮 ---*/
[UIApplication sharedApplication].idleTimerDisabled = YES;
[UIView appearance].semanticContentAttribute = UISemanticContentAttributeForceLeftToRight;
/*--- 记录NSLOG ---*/
[JLLogManager clearLog];
[JLLogManager saveLogAsFile:true];
[JLLogManager setLog:true IsMore:false Level:JLLOG_DEBUG];
[JLLogManager logWithTimestamp:true];
[Bugly startWithAppId:@"7a7c17c3ee"];
kJLLog(JLLOG_DEBUG, @"当前的语言:%@",[LanguageCls checkLanguage]);
/*--- 检测当前语言 ---*/
if ([kJL_GET hasPrefix:@"en-GB"]) {
kJL_SET("en-GB");
}else if ([kJL_GET hasPrefix:@"zh-Hans"]){
kJL_SET("zh-Hans");
}else if ([kJL_GET hasPrefix:@"ja"]){
kJL_SET("ja");
}else if ([kJL_GET hasPrefix:@"ko"]){
kJL_SET("ko");
}else if ([kJL_GET hasPrefix:@"fr"]){
kJL_SET("fr");
}
...
}
Source: AppDelegate.m
这段初始化的设计意图是把“环境准备”与“界面呈现”解耦:日志、崩溃、网络、SDK 等基础设施在业务界面出现之前就绪,后续任何页面都可以假定这些能力可用,无需自行判断。
根控制器切换(三态裁决)
初始化完成后,AppDelegate 依据合规与登录状态选择根控制器:
| 状态 | 根控制器 | 触发条件 |
|---|---|---|
| 声明未同意 | JLStatementViewController | 首次启动或声明过期 |
| 已声明未登录 | LoginVC | 用户未登录/令牌失效 |
| 已登录 | TabBarVC(主界面) | 登录成功 |
self.window.rootViewController = statementVC;
...
self.window.rootViewController = self->loginVC;
...
self.window.rootViewController = mainVC;
Source: AppDelegate.m
这种“整窗替换根控制器”的做法比连续 push 更彻底:切换后旧的层级完全释放,避免登录态下残留声明页/登录页的内存与返回栈污染。AppDelegate 通过协议回调(LoginDelegate、JLStatementViewControllerDelegate)接收子页面的完成事件,再决定下一状态。
主界面导航结构
主界面是一个 UITabBarController,其 4 个 Tab 分别承载健康、运动、设备、我的四大模块:
UINavigationController *nvc_1 = [[UINavigationController alloc] initWithRootViewController:vc_1];
UINavigationController *nvc_2 = [[UINavigationController alloc] initWithRootViewController:vc_2];
UINavigationController *nvc_3 = [[UINavigationController alloc] initWithRootViewController:vc_3];
UINavigationController *nvc_4 = [[UINavigationController alloc] initWithRootViewController:vc_4];
...
for (int i = 0 ; i < arr_vc.count; i++) {
UINavigationController *nvc = arr_vc[i];
/*--- TabBarItem的名字 ---*/
...
}
...
UINavigationController *main_nvc = [[UINavigationController alloc] initWithRootViewController:tabBarVC];
self.navigationController = main_nvc;
Source: AppDelegate.m
根据文件头的 import 列表(HealthVC.h、SportVC.h、DeviceSearchVC.h、MyVC.h),4 个 Tab 根控制器为:健康页 HealthVC、运动页 SportVC、设备搜索页 DeviceSearchVC、我的页 MyVC。
两级导航的设计意图:
- Tab 内导航:每个 Tab 自带
UINavigationController,业务页面在模块内push/pop,返回键与手势天然可用。 - App 级导航:
tabBarVC外套main_nvc并保存在self.navigationController,使与 Tab 无关的全局页面(如声明页、登录页、设置)可以整体覆盖在 TabBar 之上,且通过JLApplicationDelegate.navigationController全局可达。
BridgeHelper:全局导航访问
BridgeHelper 是跨模块获取主导航栈的桥接层(也服务于 JS Bridge 场景):
+(UINavigationController *)getNavigationController {
return JLApplicationDelegate.navigationController;
}
Source: BridgeHelper.m
业务代码(如运动详情页在本地运动开始/结束时返回根页)即通过导航栈完成页面回收:
- (void)startLocalSport {
[self.navigationController popToRootViewControllerAnimated:YES];
return;
}
Source: JLSportDetailViewController.m
核心流程:冷启动到主界面
下图按时间顺序展示 App 从冷启动到进入主界面的完整链路,以及页面间通过 delegate 回调驱动的状态迁移:
sequenceDiagram
participant OS as iOS (UIApplication)
participant AD as AppDelegate
participant INFRA as 基础设施<br/>(日志/Bugly/语言/网络/SDK)
participant ST as JLStatementViewController
participant LG as LoginVC
participant TB as TabBarVC + 4×Nav
OS->>AD: application:didFinishLaunchingWithOptions:
activate AD
AD->>INFRA: 日志/崩溃/语言/令牌/网络初始化
INFRA-->>AD: 就绪
AD->>ST: window.rootViewController = statementVC
deactivate AD
ST-->>AD: delegate: 声明已同意
activate AD
AD->>LG: window.rootViewController = loginVC
deactivate AD
LG-->>AD: delegate: 登录成功
activate AD
AD->>TB: window.rootViewController = mainVC
deactivate AD
Note over TB: 主界面内 Tab 切换与<br/>模块内 push/pop 导航
流程要点:
- 环境优先:一切 UI 呈现前完成基础设施初始化,保证日志/崩溃/网络能力随时可用。
- 声明门禁:用户协议未同意时,主界面不可达——这是合规要求在前端的第一道闸门。
- 登录门禁:声明通过后若未登录,根控制器切换为
LoginVC;登录成功经LoginDelegate回调后进入主界面。 - 主界面自洽:进入
TabBarVC后,日常导航完全由 Tab 内UINavigationController与main_nvc承接,无需再经过 AppDelegate。
使用示例
示例一:启动期根控制器切换(AppDelegate)
声明页/登录页完成后的根控制器替换逻辑集中在 AppDelegate 内部:
if (用户未同意声明) {
JLStatementViewController *statementVC = [[JLStatementViewController alloc] init];
statementVC.delegate = self;
self.window.rootViewController = statementVC;
tempVC = [[UIViewController alloc] init];
} else if (用户未登录) {
self->loginVC = [[LoginVC alloc] init];
self->loginVC.delegate = self;
self.window.rootViewController = self->loginVC;
self.window.backgroundColor = [UIColor whiteColor];
} else {
self.window.rootViewController = mainVC;
self.window.backgroundColor = [UIColor whiteColor];
}
Source: AppDelegate.m
示例二:全局导航栈获取(BridgeHelper)
任意模块需要跳转全局页面时,通过 BridgeHelper 取得主导航栈:
+(UINavigationController *)getNavigationController {
return JLApplicationDelegate.navigationController;
}
Source: BridgeHelper.m
示例三:模块内导航返回根页(运动页)
运动场景开始/结束时将页面栈回收到根控制器,避免用户在运动过程中被其他页面干扰:
- (void)startLocalSport {
[self.navigationController popToRootViewControllerAnimated:YES];
return;
}
- (void)finishLocalSport {
[self.navigationController popToRootViewControllerAnimated:YES];
return;
}
Source: JLSportDetailViewController.m
配置选项
应用架构层的主要配置点集中在 AppDelegate 启动阶段:
| 配置项 | 类型 | 默认/取值 | 说明 |
|---|---|---|---|
Bugly App ID | string | 7a7c17c3ee | 崩溃上报服务标识,启动即注册 |
| 屏幕常亮 | BOOL | YES(idleTimerDisabled) | 健康/运动场景防息屏 |
| 布局方向 | enum | UISemanticContentAttributeForceLeftToRight | 强制 LTR,统一多语言排版 |
| 日志级别 | enum | JLLOG_DEBUG | 写入文件的日志级别(setLog:IsMore:Level:) |
| 日志落盘 | BOOL | true(saveLogAsFile:) | 是否保存日志到本地文件 |
| 日志时间戳 | BOOL | true(logWithTimestamp:) | 日志是否带时间戳 |
| 语言回退 | string | "auto" | 系统语言不在支持列表时使用 |
| 网络监测 | class | AFNetworkReachabilityManager | 启动即开始可达性监听 |
| 令牌刷新 | method | refreshAccessToken | 启动时预刷新登录令牌 |
失败模式、边界情况与并发
根控制器切换的时序风险
声明页/登录页通过 delegate 异步回调 AppDelegate,而 window.rootViewController 的替换是同步赋值。设计上依赖“单回调、单入口”的顺序模型:声明必须先于登录、登录必须先于主界面。若回调在窗口未就绪时触发,可能出现切换竞态——代码中通过 tempVC 占位控制器兜底,保证窗口始终有根控制器可渲染。
语言检测的边界
语言映射采用前缀匹配(hasPrefix:),因此 zh-Hans-CN、en-GB@region 等带扩展后缀的系统语言也能正确归类;无法匹配的语言(如 fil、hr)统一回退 "auto",由 LanguageCls 按默认规则处理,避免因语言缺失导致界面文案空白。
无网络场景
AFNetworkReachabilityManager 全局监听网络状态,配合 CustomView/无网络/NoNetView.m 展示全局无网提示;令牌刷新(refreshAccessToken)在网络异常时失败后由登录/HTTP 层重试,架构层不阻塞启动。
日志与崩溃上报的副作用
每次启动 clearLog 会丢弃上一次运行的日志文件——这是以“保证磁盘占用有界”为代价换取的可诊断性;JLLogManager saveLogAsFile:true 在文件 I/O 繁忙时可能产生轻微启动延迟,属于可接受的权衡。
并发与内存
主界面由 4 个常驻 Tab 组成,各 Tab 根控制器随 TabBar 一并常驻内存;模块内页面依赖 UINavigationController 的 push/pop 生命周期自动释放。整窗替换根控制器(而非叠加 push)确保登录/声明页在切换后即被释放,是控制内存峰值的关键手段。
扩展点
- 新增 Tab 模块:在
AppDelegate的arr_vc数组中追加新的根控制器并包一层UINavigationController,按既有循环统一设置 TabBarItem 名称即可,无需改动容器架构。 - 新增全局页面:通过
BridgeHelper getNavigationController取得main_nvc后pushViewController,可整体覆盖 TabBar。 - 多语言扩展:在启动语言前缀映射链中追加
else if分支,并在LanguageCls中补充资源即可支持新语言。 - 桥接层:
Bridge/BridgeHelper.m是 JS Bridge 与原生导航的汇合点,新增 Web 与原生互跳能力时应在此扩展。