FlutenDarkModeKit 框架解析

1,623 阅读3分钟

项目介绍

FluentDarkModeKit was designed and developed before Apple‘s official dark mode release. It provides a mechanism to support dark mode for apps on iOS 11+ (including iOS 13).

FluentDarkModeKit是在苹果正式发布黑暗模式之前设计和开发的。它为iOS 11+上的应用程序(包括iOS 13)提供了一种支持暗模式的机制。

microsoft/FluentDarkMode

架构图

架构图

源码理解

该源码主要分为2部分

DarkModeCore

  • 实现动态颜色初始化和获取
  • 实现动态图片初始化和获取
  • 简化方法交换流程
  • 为swift提供命名空间,防止和UIColor和UIImage冲突
  • 为UIColor、UIImage、UIView添加扩展,实现初始化方法等
  • 类pch头文件引入

FluentDarkModeKit

  • 黑暗模式管理器
    • 初始化模式,做方法交换的工作
    • 更新当前所有显示的视图的mode
  • 扩展
    • 为view及其子类绑定dynamicProperty和更新方法
    • 为controller等提供dmTraitCollectionDidChange的处理方法

简单介绍每个文件

DarkModeCore

  • FluentDarkModeKit
    供外部调用
    引入了头文件
    定义了版本号和版本描述
    
  • DMDynamicColor
    创建DMDynamicColorProxy类继承自NSProxy
    实现初始化方法
    动态获取当前主题色
    实现NSProxy必须实现的消息转发方法
    
    判断某类型是否是当前定义类型
    
    为copy和copyWithZones返回当前队形
    
  • DMDynamicImage
    DMDynamicImageProxy动态提供适用于当前主题的UIImage对象
    其中NSProxy的使用与Color基本一致
    
    对于 UIImage 的方法中返回值为 UIImage 的,DMDynamicImageProxy 都进行了实现,目的就是当 UIImage 在调用这些方法时,返回的类型依然为 DMDynamicImageProxy。
    如:resizable、imageWithRenderingMode等
    
    同样实现copy和copyWithZone
    
  • DMTraitCollention
    全局的特征集合
    即当前用户的style是什么
    
    枚举:未指明、亮、暗
    三种模式
    
    定义协议DMTraitEnviroment
    用来更新界面
    
  • NSObject+DarkModeKit
    提供方法交换的简单调用方式
    
  • UIColor+DarkModeKit
    提供ios和swift的创建对象方式
    
  • UIImage+DarkModeKit
    同上
    
    在iOS 13上,UIImage'isEqual:'在内部发生了一些变化,
    但对'NSProxy'不起作用,
    在这里,我们手动将消息转发到内部图像
    实现
    - (BOOL)dm_isEqual:(UIImage *)other {}
    
  • UIView+DarkModeKit
    绑定dm_dynamicBackgroundColor属性给UIView
    
    交换设置背景色的方法实现
    

FluentDarkModeKit

  • DarkModeManager
  • Extensions
    • UIView+DarkModeKit
      实现setup方法
      方法交换
      
      实现更新属性
      
      扩展DMTraitEnviroment
      实现updateAppearance方法
      遍历传入视图所有子视图
      更新界面
      
    • UIToolBar+DarkModeKit
      重写dm_updateDynamicColors方法
      
      super.dm_updateDynamicColors()
      
      实现barTintColor的动态替换
      
    • UIButton+DarkModeKit
      重写dm_updateDynamicColors方法
      
      super.dm_updateDynamicColors()
      
      实现setTitleColor的动态替换
      
    • UIWindow+DarkModeKit
      实现dmTraitCollectionDidChange方法
      
      根视图rootViewController也调用dmTraitCollectionDidChange进行更新
      
    • UIProgressView+DarkModeKit
      重写dm_updateDynamicColors方法
      
      super.dm_updateDynamicColors()
      
      实现preogressTintColor和trackTintColor的动态替换
      
    • UIScrollView+DarkModeKit
      重写dm_updateDynamicColors方法
      
      super.dm_updateDynamicColors()
      
      indicatorStyle的动态返回
      
    • UITextView+DarkModeKit
      重写dm_updateDynamicColors方法
      
      super.dm_updateDynamicColors()
      
      keyboardAppearance模式的动态返回,决定键盘状态
      
    • UIImageView+DarkModeKit
      为UIImageView绑定一个dm_dynamicImage属性
      
      交换初始化init方法为dm_init
      
      交换设置图片方法setImage为dm_setImage
      
      实现dm_updateDynamicImages方法
      super.dm_updateDynamicImages()
      如果有dynamicImage则将image设置为dynamicImage
      
    • UIPageControl+DarkModeKit
      重写dm_updateDynamicColors()方法
      
      为pageIndicatorTintColor和currentPageIndicatorTintColor
      返回动态的dynamicColor
      
    • UITableView+DarkModeKit
      重写dm_updateDynamicColors()方法
      
      为sectionIndexColor和separatorColor
      返回动态的dynamicColor
      
    • UINavigationBar+DarkModeKit
      重写dm_updateDynamicColors()方法
      
      为barTintColor
      返回动态的dynamicColo
      
    • UITabBar+DarkModeKit
      extension UITabBar{}
      重写dmTraitCollectionDidChange方法
      因为tabbarController一般是根界面,上面还有好多其他Controller
      除了实现super方法,还要遍历所有item进行动态的图片及文字颜色更换
      super.dmTraitCollectionDidChange(previousTraitCollection)
      items?.forEach { $0.dmTraitCollectionDidChange(previousTraitCollection) }
      
      extension UITabBarItem: DMTraitEnviroment{}
      为item绑定两个新属性:dm_dynamicImage、dm_dynamicSelectedImage
      交换setImage方法、交换setSelectImage方法,
      为他们提供dynamicImage的设置及获取方式
      
    • UIViewController+DarkModeKit
      实现dmTraitCollectionDidChange()方法
      
      设置状态栏变化
      
      如果有present的Controller,那么调用presentController.dmTraitCollectionDidChange()
      所有children界面也都调用dmTraitCollectionDidChange()
      如果当前controller正在展示,则view调用dmTraitCollectionDidChange()
      
    • UILabel+DarkModeKit
      实现dmTraitCollectionDidChange()方法
      
      交换swizzleDidMoveToWindowOnce方法,
      当展示的时候动态更换颜色
      
      绑定currentUserInterfaceStyle属性
      
      
      实现updateDynamicColorInAttributedText()方法
      用来修改attributedText。为什么呢?因为如下:
      在iOS 11中,setNeedsDisplay()不能完全完成文字绘制
      如果设置UILabel.attributeText,则UILabel.text color将是属性文本索引0处的前景色
      如果设置UILabel.text color,则整个属性文本将获得前景色
      因此,如果一个标签有两个或多个颜色属性字符串,我们不能简单地重置文本颜色
      我们只是更新属性文本.幸运的是,我们只需要在iOS 11中这样做。
      
    • UISlider+DarkModeKit
      重写dm_updateDynamicColors()方法
      
      为minimumTrackTintColor和maximumTrackTintColor提供dynamicColor
      
    • UITextField+DarkModeKit
      重写dm_updateDynamicColors()方法
      
      为textColor提供dynamicColor
      
      动态修改keyboardAppearace属性
      
      扩展UITextField{}
      UITextfield在他的视线里不会调用super.willMove(toWindow;)
      所以我们需要交换他的实现方法,来调用willMove
      
    • UIApplication+DarkModeKit
      继承协议DMTraitEnvironment
      实现dmTraitCollectionDidChange方法
      遍历windows的所有子界面,调用dmTraitCollectionDidChange()状态变更方法
      

补充知识讲解

NSProxy

NSProxy: 是一个抽象基类,它为一些表现的像是其它对象替身或者并不存在的对象定义API。

一般的,发送给代理的消息被转发给一个真实的对象或者代理本身引起加载(或者将本身转换成)一个真实的对象。

NSProxy的基类可以被用来透明的转发消息或者耗费巨大的对象的lazy 初始化。

NSProxy实现了包括NSObject协议在内基类所需的基础方法,但是作为一个抽象的基类并没有提供初始化的方法。

它接收到任何自己没有定义的方法他都会产生一个异常,

所以一个实际的子类必须提供一个初始化方法或者创建方法,

并且重载forwardInvocation:方法和methodSignatureForSelector:方法来处理自己没有实现的消息。

一个子类的forwardInvocation:实现应该采取所有措施来处理invocation,比如转发网络消息,或者加载一个真实的对象,并把invocation转发给他。

methodSignatureForSelector:需要为给定消息提供参数类型信息,子类的实现应该有能力决定他应该转发消息的参数类型,并构造相对应的NSMethodSignature对象。

详细信息可以查看NSDistantObject, NSInvocation, and NSMethodSignature的类型说明。

相信看了这些描述我们应该能对NSProxy有个初步印象,它仅仅是个转发消息的场所,至于如何转发,取决于派生类到底如何实现的。

比如我们可以在内部hold住(或创建)一个对象,然后把消息转发给该对象。那我们就可以在转发的过程中做些手脚了。

甚至也可以不去创建这些对象,去做任何你想做的事情,但是必须要实现他的forwardInvocation:和methodSignatureForSelector:方法。

消息转发机制

当程序运行时调用一个没有实现的方法,会采用三个消息转发步骤如果这三个步骤都不能成功那么此时程序会抛出一个异常。

  • 添加方法到类对象,对于实例方法调用respondsToSelector:,对于类方法调用resolveClassMethod:。
    + (BOOL)resolveClassMethod:(SEL)sel;
    - (BOOL)respondsToSelector:(SEL)aSelector;
    
  • 查找forwardingTargetForSelector:方法,该方法返回一个新对象,如果返回nil那么将跳转到下一步骤。
    - (id)forwardingTargetForSelector:(SEL)aSelector;
    
  • 通过methodSignatureForSelector:方法获取一个NSMethodSignature类型的对象,调用forwardInvocation:方法。改方法传入一个封装了NSMethodSignature的NSInvocation对象。然后该对象通过invakeWithTarget:方法将消息转发给其它对象。
    - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector;
    - (void)forwardInvocation:(NSInvocation *)invocation;
    

NSProxy 和 NSObject 的比较

相比NSObject,NSProxy更轻量级, 做消息转发效率更高.

NSObject寻找方法顺序:本类->(父类-)>(动态方法解析)-> 消息转发;

NSproxy顺序:本类->消息转发

NSProxy的用途

  • AOP面向切片编程:iOS中面向切片编程一般有两种方式 ,一个是直接基于runtime 的method-Swizzling.还有一种就是基于NSProxy
  • 解决NSTimer, CADisplayLink等强引用target引起的无法释放问题。如NSTimer:利用消息转发来断开NSTimer对象与视图之间的强引用关系。初始化NSTimer时把触发事件的target替换成一个单独的对象,然后这个对象中NSTimer的SEL方法触发时让这个方法在当前的视图self中实现。
  • 多重继承,实现类似CAAnimation类族.

如何使用

UILabel

Swift

extension UIColor {
    init(_: DMNamespace, light: UIColor, dark: UIColor)
}

let color = UIColor(.dm, light: .white, dark: .black)

Objective-C

@interface UIColor (FluentDarkModeKit)
- (UIColor *)dm_colorWithLightColor:(UIColor *)lightColor darkColor:(UIColor *)darkColor;
@end

UIColor *color = [UIColor dm_colorWithLightColor:UIColor.whiteColor darkColor:UIColor.blackColor];

Images

Swift

extension UIImage {
    init(_: DMNamespace, light: UIImage, dark: UIImage)
}

let lightImage = UIImage(named: "Light")!
let darkImage = UIImage(named: "Dark")!
let image = UIImage(.dm, light: lightImage, dark: darkImage)

Objective-C

@interface UIImage (FluentDarkModeKit)
- (UIImage *)dm_imageWithLightImage:(UIImage *)lightImage darkImage:(UIImage *)darkImage;
@end

在Objective-C中如何集成和使用

众所周知,国内在使用Swift的进度上一直很喜人。

绝大部分项目都是由OC构建为主;

接下来我们探讨一下如何在OC项目中集成该组件。

纯OC项目

  1. 新建一个swift文件,这时候会同步创建一个xxx-Bridging-Header.h文件
  2. 修改Target的BuildSettings中Defines ModuleYes

Podfile

修改Podfile

target 'dmSample' do
  # Comment the next line if you don't want to use dynamic frameworks
  # 我们这里是swift混编项目所以,需要使用这个动态库标识
  use_frameworks!

  # Pods for dmSample
  pod "FluentDarkModeKit"

end

在终端调用

cd xxx文件夹
pod install

新增DarkModeInit.swift文件

import Foundation
import FluentDarkModeKit

class DarkModeInit: NSObject {
    @objc func useToInit() {
        DarkModeManager.setup()
    }
}

在AppDelegate中调用初始化方式

首先引入xxx-Swift.h,可以在BuildSetting中看到

#import "dmSample-Swift.h"

DidFinishLanuchingWithOptions中调用初始化

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    
    [[DarkModeInit new] useToInit];
    
    return YES;
}

实现一个简单得到界面

#import "ViewController.h"
// 引入iOS
#import <FluentDarkModeKit/FluentDarkModeKit.h>
// 引入swift调用
@import FluentDarkModeKit;

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.view.backgroundColor = [UIColor dm_colorWithLightColor:[UIColor whiteColor] darkColor:[UIColor lightGrayColor]];
    
    UILabel *showLabel = [UILabel new];
    showLabel.frame = CGRectMake(30, 88, 300, 200);
    showLabel.text = @"深色模式使用的很舒服哈罗闪蝶恋蜂狂哈迪斯发了哈开始的合法ad控件是否雷克萨货到付款哈市的";
    showLabel.numberOfLines = 0;
    // 设置颜色
    showLabel.textColor = [UIColor dm_colorWithLightColor:[UIColor blackColor] darkColor:[UIColor redColor]];
    [self.view addSubview:showLabel];
    
    UIButton *btn = [[UIButton alloc] initWithFrame:CGRectMake(30, 200, 100, 100)];
    [btn setTitle:@"切换" forState:UIControlStateNormal];
    [btn setTitleColor:[UIColor dm_colorWithLightColor:[UIColor grayColor] darkColor:[UIColor blueColor]] forState:UIControlStateNormal];
    [self.view addSubview:btn];
    [btn addTarget:self action:@selector(changeStyle) forControlEvents:UIControlEventTouchUpInside];
}

- (void)changeStyle {
    NSLog(@"切换模式");
    if (DMTraitCollection.currentTraitCollection.userInterfaceStyle == DMUserInterfaceStyleDark) {
        DMTraitCollection.currentTraitCollection = [DMTraitCollection traitCollectionWithUserInterfaceStyle:DMUserInterfaceStyleLight];
    } else {
        DMTraitCollection.currentTraitCollection = [DMTraitCollection traitCollectionWithUserInterfaceStyle:DMUserInterfaceStyleDark];
    }
    // 更新界面
    [DarkModeManager updateAppearanceFor:[UIApplication sharedApplication] animated:YES];
}


@end

补充:

如何支持iOS9

作者这两天在基于这个框架进行主题定制、暗黑适配的开发。

目前寺内很多项目是最低版本支持到9.而微软这套推荐iOS10+

所以笔者仔细查看了源码,不支持iOS9的是因为项目内使用了 UIViewPropertyAnimation这个API是iOS10以后才可以使用。

具体用处是在DarkModeManager.swift文件内。我们只需要将此文件修改为UIViewAnimation代替即可。

然后在自己寺内的gitlab或其它地方,将podspec进行修改,上传到自己的公有库或私有库,即可完成对iOS9的支持;

如果有的同学想知道如何适配iOS8或更早的版本!

虽然以上可以支持iOS9的项目集成,但是笔者测试之后发现,大家还是最好做个版本限制;

因为Apple在iOS11之后对很多视图进行了层级上面的修正。如果你的团队没有大量的时间进行定制化开发,听微软的!

if @avaliable(ios11, *) {
    play.time!!!!
}

同学,劝劝老板放弃老版本用户吧。因为他们不喜欢新时代!

修改的源码如下:

// MARK: -

extension DMTraitEnvironment {
  /// Trigger `themeDidChange()`.
  ///
  /// - Parameters:
  ///   - views: Views visiable by user, will be snapshoted if use animation.
  ///   - animated: Use animation or not.
  fileprivate func updateAppearance(with views: [UIView], animated: Bool) {
    assert(Thread.isMainThread)

    if animated {
      var snapshotViews: [UIView] = []
      views.forEach { view in
        guard let snapshotView = view.snapshotView(afterScreenUpdates: false) else {
          return
        }
        view.addSubview(snapshotView)
        snapshotViews.append(snapshotView)
      }

      dmTraitCollectionDidChange(nil)
        
        UIView.animate(withDuration: 0.25, animations: {
            snapshotViews.forEach { $0.alpha = 0}
        }) { _ in
            snapshotViews.forEach { $0.removeFromSuperview() }
        }

        // 10.0才有的新api,动画会更顺畅,但是支持效果比较低。
//      UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.25, delay: 0, options: [], animations: {
//        snapshotViews.forEach { $0.alpha = 0 }
//      }) { _ in
//        snapshotViews.forEach { $0.removeFromSuperview() }
//      }
    }
    else {
      dmTraitCollectionDidChange(nil)
    }
  }
}