iOS组件化中的宏处理杂谈

2,813 阅读7分钟

原文地址

前言

几乎所有C家族的语言都在长期使用预处理器带来的宏功能,比如:常量定义、条件编译、代码生成、神奇的“语法糖”等......

但是如此好用的功能,我们务必要防止滥用,由于其作用面之大,可能因使用不当而带来的问题也会奇奇怪怪,所以本文的内容将围绕宏在iOS组件化中时所要面对的种种问题来进行阐述,最后会对所有问题进行一个总结并给出一个比较通用的使用建议。

二进制化之前的问题

宏的隔离性

这里我们将公开给外部使用的宏称为导出宏,而在组件化中,由于组件数量难以估计,甚至在某些大的公司有很多不同部门不同组的同事分别开发维护多个组件,而跨组跨部门沟通经常需要更多的成本,所以应尽量保持少导出宏,通过其他方式比如const常量、函数/方法声明向外界导出接口或数据,避免造成宏和宏、宏和变量、函数等同名出现的冲突,这种冲突在没有对编译器做一些设置的情况下往往只会产生警告,而不会出现明显的报错,导致在运行时出现莫名其妙的BUG。

Clang模块化对宏的影响

当某组件内依赖其他组件(外部)的宏,这里的依赖方式,需要注意一个问题,那就是你是显式的依赖(已经显式导入依赖的头文件,这里并不会产生问题),还是隐式的依赖,隐式的依赖可能不太好理解,这里我举个例子:

// ModuleA.h
#define ModuleA_Var 1

// ModuleB.h
# ifdef ModuleA_Var
	#define ModuleB_Var 1
#else
	#define ModuleB_Var 2
#endif

// Business.m
#import <ModuleA/ModuleA.h>
#import <ModuleB/ModuleB.h>

NSLog(@"%d",ModuleB_Var);

可以看到,业务方同时导入了ModuleA和ModuleB,而ModuleB实际上是依赖ModuleA中的宏的,但是却没有显式的导入它,而是在业务方代码里同时被一起引用了,这里我将它称为隐式的依赖。那么这里业务方打印这个ModuleB_Var是1还是2呢?

这里就引出了一个相当需要注意的问题,我们知道预处理器对于宏定义只是简单的文本替换,在使用的组件没有开启Module化时,这里的导入关系其实是依赖你的#import指令的先后顺序的,上面的例子里,打印出的会是1,而如果你将导出顺序交换一下,就会导致打印的是2;而在组件都开启Module化,业务方也开启使用Module功能之后(目前Xcode默认都会开启这两个选项),这时候就不再是简单的文本替换,#import指令会根据是否满足条件(符合Module标准)自动变成@import指令,也就是从简单的文本导入变成模块导入,clang将产生一个干净上下文的预处理器来编译这个模块的头文件,并以二进制的形式缓存结果,以便重复给其他导入该模块的编译单元使用。所以在其之前导入的头文件ModuleA内容,实际上ModuleB是感知不到的,也许有人会觉得自己不会写这种代码,但是如果把ModuleA中的宏这个条件改成是来自PCH文件中的宏呢?这时候就更难察觉了。

所以这里的问题,其实就引出一个结论,那就是组件内的头文件不要依赖任何自己没有显示导入,而在导入自己之前在其他文件(其他组件)定义的宏,因为我们的代码要具备一定的健壮性,不管模块化是否开启,都应拥有同样的执行效果。

宏的可变性

实际上如果将宏进行一个大方向的分类,可以分为可变的和不可变的,其中不可变的较为常用,也更为通用,带来的问题也更少;而可变的宏一般使用在环境的切换中,其中某些特殊的热门三方库由于其考虑兼容性而需要添加一些预编译宏,这些实际上从使用者角度来说,其应该算是一个不可变的宏,因为可变与不可变性是来自使用者,而不是来自提供者。

那可变的宏会带来哪些问题呢?因为本文是按照二进制化之前和二进制化之后来区分问题阐述的,所以这里只引出在二进制化之前的问题,可变的宏在这里会带来一些编译性能的问题,因为其可变性造成了部分缓存失效而触发重新编译,倒并没有带来结果性的影响。

二进制化时面对的问题

前面已经阐述的问题,并不意味着二进制化后就没有那些问题,其实这是累加的,二进制化是在其基础上对宏的使用带来更加苛刻的条件,我们继续说。

宏的消失

从标题来看就很清楚,因为一旦二进制化,组件内的宏因为已经完成编译,所以其内的宏就已经被替换完成,这里所说的消失指的是其被编译的实现文件,而不是指头文件。接上面宏的可变性继续说,如果宏不可变,那么二进制化后宏的消失也并不会出现问题,问题在于可变宏,因为其已经被替换完成,所以它不会再响应可变宏未来的任何变化,只能重新编译。

对于这种问题,我们可以按两种情况进行处理:

  1. 方法内部使用宏

    对于这种情况,其二进制化后宏消失的问题,我们可以提供一个始终不会被二进制化的源码组件(宏组件),将所需的条件判断宏向外(也就是向二进制化的组件方)提供方法或函数接口,而这个宏组件在函数或方法内部通过判断宏来返回数据,大概如下:

    // 宏失效情况
    // ModuleA内部
    - (void)xxxx {
    	#if DEBUG
    		// code
    	#else
    		// code
    	#endif
    }
    
    // 处理之后
    // ModuleMacro
    // Macro.h
    + (BOOL)isDebug;
    // Macro.m
    + (BOOL)isDebug {
    	#if DEBUG
    		return YES;
    	#else
    		return NO;
    	#endif
    }
    
    // ModuleA内部
    - (void)xxxx {
    	if ([Macro isDebug]){
    		// code
    	} else {
    		// code
    	}
    }
    
  2. 方法外部使用宏

    而在方法外部使用的宏,解决方案就是通过重构将其改为方法内部使用的宏,比如:

    // 宏失效情况
    #if DEBUG
    NSString *const BaseAPI = @"<https://xxx>";
    #else
    NSString *const BaseAPI = @"<https://xxx>";
    #endif
    
    // 处理之后
    - (NSString *)baseAPI {
    #if DEBUG
    	return @"<https://xxx>";
    #else
    	return @"<https://xxx>";
    #endif
    }
    

如何安全的使用宏

关于如何更安全的使用宏,这里我给出以下几点建议:

  1. 应该尽量避免组件向外导出宏和导入外部组件的宏,可变宏就不用多说,肯定不要导入或导出,而不可变的宏,如果能保证其宏接口的稳定性,可以适当使用,比如处理闭包循环引用的宏等。
  2. 在满足第1条的情况下,不要隐式依赖外部组件的宏,而应该显式导入,否则会出现依赖导入顺序和模块化后宏消失问题。
  3. 在满足第2条情况,不要出现两个组件循环依赖(这一条其实是属于组件化拆分的问题)。

结语

由于本人水平有限,如有发现勘误之处希望能被指出,同时也希望这些内容能够为需要的同学带来用处。