Tencent / QMUI_iOS

QMUI iOS——致力于提高项目 UI 开发效率的解决方案
http://qmuiteam.com/ios
Other
7.05k stars 1.37k forks source link

在尚未添加到 window 的 UINavigationController 里修改 navigationBar 可能会导致 navigationBar 样式错误 #1451

Closed MoLice closed 1 year ago

MoLice commented 1 year ago

Bug 表现 项目里为全局的返回按钮设置了一个图片:

image

某些情况下这个返回按钮的图片会恢复为系统默认的:

image

如何重现

  1. 界面上显示一个 UITabBarController,里面添加多个 vc。
  2. 每个 vc 都实现 qmui_themeDidChangeByManager:identifier:theme: 方法,在里面修改 UINavigationBar 的样式,例如 self.navigationController.navigationBar setBackgroundImage:xxx
  3. 启动 App,不要切换任何界面,直接在默认的第一个 vc 里触发 theme 切换(例如调整系统设置的开关,或者把 App 回到桌面再唤醒)。
  4. 再切到其他 tab,可以观察到这些 tab 的返回按钮被恢复为系统默认的图片。

其他信息

MoLice commented 1 year ago

Bug 解析

当 QMUITheme 切换时,会对当前所有 windows 的 rootViewController 调用他们的 qmui_themeDidChangeByManager:identifier:theme:,而该方法的默认实现里会对所有的 childViewControllers 都触发相同的回调。

对于 UITabBarController 而言,它里面的 childViewControllers 是在 UITabBarController init 时一次性加上去的,但只有切换到某个具体的 tab index 时,对应的 childViewController 的 view 才会被加载并添加到 window 上,如果这个 childViewController 是一个 UINavigationController,那它的 UINavigationBar 也是在这时候才被添加到 window 上。

QMUI 配置表里对 UINavigationBar 的全局配置,内部是通过 UIAppearance 来实现的,而 UIAppearance 的特性就是在某个 UIView 实例被添加到 window 上时,它才会从 UIAppearance 里读取值,并自动设置到当前 UIView 实例里,但如果你在添加到 window 前就已经修改了这个实例的某些属性,则这些属性在后续应用 UIAppearance 的过程中不会被再次修改。

再者,系统在 iOS 13 时,创建了一套新的修改 UINavigationBar 样式的接口:UINavigationBarAppearance,新旧两套接口是互斥关系,QMUI 为了让业务项目平滑兼容两套接口,在 UINavigationBar+QMUI 里做了 hook,把旧接口的调用都转为新接口。

image

所以在上图的代码里,在用旧接口修改 UINavigationBar 样式时,会触发①,①里会通过②转为新接口,在新接口的设置过程中,需要先通过③拿到当前 bar 的样式,修改后在④里赋值回去。

如果是正常情况,当前 bar 先被添加到 window 上,然后被 QMUI 全局配置的 UIAppearance 应用了正确的样式,后续再触发这些新旧接口的 hook 时,它在③里拿到的就是已经被 UIAppearance 修改后的值,后续的修改也是基于全局配置的基础上再修改,这样是没问题的。

但如果是本 issue 描述的场景,在 QMUITheme 切换时,尚未被添加到 window 上的 childViewController 也会收到回调,在回调里去修改 UINavigationBar 的样式,此时由于这些 UINavigationBar 还没应用 UIAppearance,在步骤③里得到的其实是系统默认的值,修改后在④赋值回去,就会导致后续即便这些 UINavigationBar 被添加到 window 上了,系统也会认为你之前已经设置过自己的值(④),就不会再为你应用 UIAppearance,所以你的 UINavigationBar 就会一直保持系统默认的样式不变,表现出来就是返回按钮与你全局配置的图片不一样。

解决方式

如果你的项目使用 UIAppearance 来管理全局的 view 样式,则尽量避免在对应 view 的 didMoveToWindow 之前就去修改它的样式,所以这种场景请自行移除相关业务代码。

同时 QMUI 在 UINavigationBar(QMUI) 里会 hook setStandardAppearance:,判断如果 setter 被触发时 bar.window 为空,则给出 QMUIAssert 提示。

也即 QMUI 仅对这种场景做提示,但不做任何兜底保护。

qmui_themeDidChangeByManager:identifier:theme: 的考虑

该方法目前的实现如下:

image

如果我们只讨论 issue 这个场景,那么其实有一个更稳妥的修改方式,就是在 -[UIViewController qmui_themeDidChangeByManager:identifier:theme:] 默认实现里,针对 self 不同的类型做不同的处理,保证只对当前可视的 viewController 才会调用这个方法,这样业务在这个方法里不管怎么修改 appearance 都是安全的。类似的伪代码如下:

@implementation UIViewController (QMUITheme)

- (void)qmui_themeDidChangeByManager:(QMUIThemeManager *)manager identifier:(__kindof NSObject<NSCopying> *)identifier theme:(__kindof NSObject *)theme {
    if (presentedViewController) {
        [presentedViewController qmui_themeDidChangeByManager:manager identifier:identifier theme:theme];
        return;
    }
    if ([self isKindOfClass:UITabBarController.class]) {
        [selectedViewController qmui_themeDidChangeByManager:manager identifier:identifier theme:theme];
        return;
    }
    if ([self isKindOfClass:UINavigationController.class]) {
        [topViewController qmui_themeDidChangeByManager:manager identifier:identifier theme:theme];
        return;
    }
    [self.childViewControllers enumerateObjectsUsingBlock:^(__kindof UIViewController * _Nonnull childViewController, NSUInteger idx, BOOL * _Nonnull stop) {
        [childViewController qmui_themeDidChangeByManager:manager identifier:identifier theme:theme];
    }];
}

@end

但这样改会带来新的问题,如果只触发可视的 childViewController 的回调,则在 theme 切换后,从可视的 child 回到前一个 child,前面这个 child 无法感知到在它的生命周期内曾经有 theme 被切换过。假如这个 child 在内部有一些“记录当前是哪个 theme”的行为,则这些行为也会出错,并且唯一代替这个回调的方式就只有自己监听 QMUIThemeDidChangeNotification,相对而言比较绕。

qmui_themeDidChangeByManager:identifier:theme: 设计的初衷就是希望为 UIViewController 提供一个便捷可靠的响应 theme 切换的方式——在这种情况下,冗余的调用比“猜测你可能需要响应的时机”更重要,因为猜测总是有不准确的时候。

综上,这里还是维持触发所有 childViewControllers 回调的做法,至于“这个回调可能比 viewWillAppear:、viewDidAppear: 都要早”的问题,暂时以注释的方式提醒,最终交给开发者自己处理。

MoLice commented 1 year ago

已修复该问题,请根据 iOS 版本支持情况选择升级到 4.6.0(iOS 11-16)4.6.1(iOS 13-16)