Flutter & iOS问题记录 - 多环境配置下Pod库的宏定义失效

文章目录

  • 前言
  • 开发环境
  • 问题描述
  • 问题分析
    • 1. 创建用于测试的Pod库
    • 2. 验证问题是否只存在于Pod库
    • 3. __OPTIMIZE__在什么时候会定义
    • 4. 影响__OPTIMIZE__定义的优化编译设置
    • 5. Pods工程的优化编译设置
    • 6. 自动修正Pods工程的优化编译设置
    • 7. Pods工程的多环境配置分析
      • 7.1. pods_project所属的类
      • 7.2. build_configurations的定义
      • 7.3. add_build_configuration的定义
      • 7.4. add_build_configuration的调试
      • 7.5. 构建设置的来源
      • 7.6. 配置类型的决定
      • 7.7. 配置类型的设置
      • 7.8. project配置的使用
      • 7.9. Podfile配置的作用范围
  • 解决方案
  • 总结
  • 最后


前言

开发Flutter插件封装一些原生代码时,遇到的一个奇怪的问题。这个问题虽小,但也很值得分析。本篇文章讲的比较细比较长,如果你能坚持阅读完,我相信你一定会有所收获。

开发环境

  • Xcode: 14.2
  • Cocoapods: 1.12.0

问题描述

Flutter插件中的原生代码运行时没有任何日志输出,但是通过断点调试可以确定是有执行的。

原生代码是用Objective-C写的,日志输出调用的的是NSLog方法,跳转到NSLog方法定义的地方,可以看到如下代码:

#ifndef __OPTIMIZE__
#define NSLog(...) NSLog(__VA_ARGS__)
#else
#define NSLog(...){}
#endif

问题分析

NSLog方法的宏定义可以看出,只有没定义 __OPTIMIZE__时,日志才能正常输出,那__OPTIMIZE__在什么时候会定义呢?如果你去搜索,很多文章会告诉你Release环境下会定义,Debug环境下不定义,也就是说这段代码的作用是屏蔽Release环境下的日志输出。这似乎是很常见的做法,那是当前项目运行环境有问题吗?多次确认当前项目运行的是Debug环境没错。

难道是因为Flutter插件项目的问题?感觉不太可能,不过Flutter插件项目是以Pod库的方式依赖的,这么一想,会不会这个问题只出现在被依赖的Pod库中?

1. 创建用于测试的Pod库

新建一个无关Flutter的Pod库试试,切换到根路径下执行命令:

pod lib create LogTest

这条命令的作用是创建一个名为LogTest的Pod库。执行后需要一步步完善信息,因为只是用于简单测试,不需要发布,所以除了platform要选iOSlanguage要选ObjC外,其他的随意填写选择。

创建完毕后,为了方便往库里添加代码,先修改Podfile文件依赖刚创建的LogTest库:

target 'xxx' do
  pod 'LogTest', :path => './LogTest'
end

执行命令让依赖生效:

pod install

现在一个空的Pod库已经准备好,打开Xcode项目,在[Pods工程] -> [Development Pods]可以看到LogTest,选中它后按下快捷键command + N然后选Cocoa Touch Class快速创建LogTest.hLogTest.m

LogTest.h

#import <Foundation/Foundation.h>

#ifndef __OPTIMIZE__
#define NSLog(...) NSLog(__VA_ARGS__)
#else
#define NSLog(...){}
#endif

NS_ASSUME_NONNULL_BEGIN

@interface LogTest : NSObject

+ (void)log:(NSString *)msg;

@end

NS_ASSUME_NONNULL_END

LogTest.m

#import "LogTest.h"

@implementation LogTest

+ (void)log:(NSString *)msg {
    NSLog(@"%@", msg);
}

@end

到这一步,测试用的Pod库还未完全准备好,新建的LogTest.hLogTest.m文件需要从LogTest库根目录移到LogTest库根目录/LogTest/Classes,这是因为在LogTest.podspec已经指定了源码路径(s.source_files = 'LogTest/Classes/**/*'),不放在指定路径下无效。

做完以上操作,重新执行pod install,就可以在主工程内调用新建的Pod库啦!

注意:如果主工程使用的是Swift语言,Podfile中又没有配置use_modular_headers!,那么需要在桥接文件xxx-Bridging-Header.h中加上#import

2. 验证问题是否只存在于Pod库

在主工程选个位置调用:

LogTest.log("这是测试")

发现Debug环境下还是没有日志输出,那么说明跟Flutter没有关系,这是iOS原生的问题。那如果在主工程使用这个宏定义正常吗?经测试,在主工程使用这个宏定义是正常的。

3. __OPTIMIZE__在什么时候会定义

前面提到__OPTIMIZE__在Release环境下会定义,不止一篇文章这么说,而且在主工程内测试也是没问题,真实性应该是没问题的。那么问题出在了哪里呢?

经过一番搜索,我找到了关于__OPTIMIZE__的文档说明,它是GCC的预定义宏:

__OPTIMIZE__
__OPTIMIZE_SIZE__
__NO_INLINE__

These macros describe the compilation mode. __OPTIMIZE__ is defined in all optimizing compilations. __OPTIMIZE_SIZE__ is defined if the compiler is optimizing for size, not speed. __NO_INLINE__ is defined if no functions will be inlined into their callers (when not optimizing, or when inlining has been specifically disabled by -fno-inline).

These macros cause certain GNU header files to provide optimized definitions, using macros or inline functions, of system library functions. You should not use these macros in any way unless you make sure that programs will execute with the same effect whether or not they are defined. If they are defined, their value is 1.

引自:https://gcc.gnu.org/onlinedocs/gcc-12.2.0/cpp/Common-Predefined-Macros.html

文档虽然内容不多,但是明确说明了__OPTIMIZE__会在所有优化编译中定义。这似乎和前面说的有点区别,Debug环境也不是不能设置优化编译,如果Debug环境设置了优化编译,那也会预定义宏__OPTIMIZE__。分析到这,感觉离真相越来越近,接下来就是检查优化编译设置。

4. 影响__OPTIMIZE__定义的优化编译设置

GCC文档中并没有优化编译设置相关的内容,不过按以往的经验,既然已经知道和GCC相关,直接在项目构建设置中搜索应该是能找到的。为了方便分析,我新建了一个Xcode项目,并按之前的项目通过自定义多个构建配置实现多环境(Dev/Pre/Prod,Dev属于Debug环境,Pre/Prod属于Release环境)。

Flutter & iOS问题记录 - 多环境配置下Pod库的宏定义失效_第1张图片

在主工程的项目构建设置搜索关键词GCC

Flutter & iOS问题记录 - 多环境配置下Pod库的宏定义失效_第2张图片

猜测Optimization Level应该就是我们要找的优化编译设置,再做进一步验证之前,先简单了解一下这个构建设置。

Optimization Level
Setting name: GCC_OPTIMIZATION_LEVEL
Specifies the degree to which the generated code is optimized for speed and binary size.

  • None: Do not optimize. [-O0] With this setting, the compiler’s goal is to reduce the cost of compilation and to make Debugging produce the expected results. Statements are independent—if you stop the program with a breakpoint between statements, you can then assign a new value to any variable or change the program counter to any other statement in the function and get exactly the results you would expect from the source code.
  • Fast: Optimizing compilation takes somewhat more time, and a lot more memory for a large function. [-O1] With this setting, the compiler tries to reduce code size and execution time, without performing any optimizations that take a great deal of compilation time. In Apple’s compiler, strict aliasing, block reordering, and inter-block scheduling are disabled by default when optimizing.
  • Faster: The compiler performs nearly all supported optimizations that do not involve a space-speed tradeoff. [-O2] With this setting, the compiler does not perform loop unrolling or function inlining, or register renaming. As compared to the Fast setting, this setting increases both compilation time and the performance of the generated code.
  • Fastest: Turns on all optimizations specified by the Faster setting and also turns on function inlining and register renaming options. This setting may result in a larger binary. [-O3]
  • Fastest, Smallest: Optimize for size. This setting enables all Faster optimizations that do not typically increase code size. It also performs further optimizations designed to reduce code size. [-Os]
  • Fastest, Aggressive Optimizations: This setting enables Fastest but also enables aggressive optimizations that may break strict standards compliance but should work well on well-behaved code. [-Ofast]
  • Smallest, Aggressive Size Optimizations: This setting enables additional size savings by isolating repetitive code patterns into a compiler generated function. [-Oz]

引自:https://developer.apple.com/documentation/xcode/build-settings-reference#Optimization-Level

根据Xcode官方给的文档,可以看出不同的优化级别其实就是对代码执行速度和二进制文件大小进行不同程度的优化。

  • Dev默认的优化级别是None[-O0],不做优化编译,这是为了更快的编译速度(通常来说,优化程度越高编译越耗时)和方便断点调试
  • Pre/Prod默认的优化级别是Fastest, Smallest[-Os],在启用Faster[-O2]中所有不会增加代码大小优化项的同时对代码大小进一步优化,用更慢的编译速度换来代码执行速度的提升和二进制文件大小的减小

接下来就是验证__OPTIMIZE__的定义是不是真的受这个设置影响。将Dev和Pre/Prod中的优化级别设置互换:

screenshot3

经测试,Dev环境下出现了预定义宏__OPTIMIZE__,Pre/Prod环境反而没有了。这结果和预期一致,可以说影响__OPTIMIZE__定义的优化编译设置就是Optimization Level

5. Pods工程的优化编译设置

分析到这,已经可以确定Pods工程的优化编译设置肯定是有问题的,接下来就是验证这一想法。在Pods工程的项目构建设置搜索关键词Optimization Level

Flutter & iOS问题记录 - 多环境配置下Pod库的宏定义失效_第3张图片

从图中可以看到,和主工程的项目构建设置相比,多了Debug和Release构建配置,这两个项目创建时自带的构建配置明明已经删掉了,在Pods工程竟然还存在。最重要的是,Dev的优化级别竟然是Fastest, Smallest[-Os],怪不得Pod库在Dev环境也会有预定义宏__OPTIMIZE__,将优化级别手动修改为None[-O0],再次测试Pod库中的日志输出,一切正常!除了修改项目的构建设置,也可以通过修改目标中Pod库的构建设置实现日志正常输出。

补充两点关于构建设置的知识:

  1. 目标的构建设置继承于项目的构建设置
  2. 目标的构建设置优先级高于项目的构建设置

简单来说,当目标构建设置没有自定义时,会和项目构建设置保持一致;当目标构建设置存在自定义时,以目标构建设置为准。在Xcode中想要更加直观地查看构建设置层次,只需要选择Levels过滤器:

Flutter & iOS问题记录 - 多环境配置下Pod库的宏定义失效_第4张图片

如果要关闭层次显示,选择Combined即可。上图中Resolved列的值是Xcode构建时最终生效的值。想详细了解更多关于这方面的内容,请看Xcode官方文档介绍。

用手动的方式修改构建设置,每次执行pod install都会重置,这肯定不是我们所想要的,那有没有更好的办法呢?

6. 自动修正Pods工程的优化编译设置

我们可以利用post_install这个Hook实现自动修正Pods工程的优化编译设置。

在这之前,首先要知道优化编译设置在配置文件中的名称,这个不难,在前面关于Optimization Level的官方文档中已经说了是GCC_OPTIMIZATION_LEVEL。然后是确定不同的优化级别设置在配置文件中对应具体什么值,找到Xcode项目根目录下的[xxx.xcodeproj]文件 -> 右键显示包内容 -> 打开[project.pbxproj]文件,搜索GCC_OPTIMIZATION_LEVEL,可以发现None[-O0]对应的值是0Fastest, Smallest[-Os]对应的是s(如果你没修改过优化级别设置,可能搜索不到,因为不设置时默认优化级别就是这个)。

准备就绪,接下来找到Pods项目(pods_project)中的构建配置数组(build_configurations),遍历找到Dev环境的构建配置,最后修改构建设置(build_settings)中的GCC_OPTIMIZATION_LEVEL的值为0

Podfile文件中加上:

post_install do |installer|
  installer.pods_project.build_configurations.each do |config|
    if config.name == 'Dev'
      config.build_settings['GCC_OPTIMIZATION_LEVEL'] = '0'
      break
    end
  end
end

加上这个Hook后,每次执行pod install都会自动修正Pods工程的优化编译设置。

问题分析到此就结束了吗?当然没有,还有一个很关键的疑问没有解决,为什么Pods工程的多环境配置没有和主工程保持一致?

7. Pods工程的多环境配置分析

Pods工程的多环境配置相比主工程,一是多了Debug和Release构建配置,二是存在构建设置不一致的情况。

前面我们通过拿到Pods项目对象和构建配置数组实现了自动修正优化编译设置,既然有构建配置数组,那CocoaPods中肯定有地方往这个数组里面添加Dev等构建配置,在添加构建配置的位置打断点调试不就能知道Debug和Release的构建配置是怎么来的。现在思路有了,接下来就是找到合适的地方打断点调试。

7.1. pods_project所属的类

想知道pods_project对象所属的类不难,只需要在代码块中加上一行代码:

post_install do |installer|
  p installer.pods_project
  ...
end

这行代码的作用就是打印pods_project对象,执行pod install输出:

# path:`xxx/app/Pods/Pods.xcodeproj` UUID:`xxx`

就是我们需要的,这表示pods_project对象所属的类是Pod模块下的Project类

7.2. build_configurations的定义

打开CocoaPods文档,找到Project类搜索build_configurations

Flutter & iOS问题记录 - 多环境配置下Pod库的宏定义失效_第5张图片

没有找到关于build_configurations的定义,那可能是在Xcodeproj::Project父类中定义的。打开Xcodeproj文档,找到Project类搜索build_configurations

Flutter & iOS问题记录 - 多环境配置下Pod库的宏定义失效_第6张图片

从定义源码可以看到,build_configurations原来不是数组,是一个方法,不过方法的返回值确实是数组。

7.3. add_build_configuration的定义

定义是找到了,但是往数组里面添加构建配置的位置没搜索到。不过,既然获取数组功能封装成了方法,我猜往数组里面添加对象应该也封装成了方法,方法命名很可能是add_build_configuration。果不其然,真有这个方法:

Flutter & iOS问题记录 - 多环境配置下Pod库的宏定义失效_第7张图片

这部分代码对于本篇文章很重要,后面再具体分析。简单来说,这部分代码的作用就是创建构建配置对象存到数组并返回这个构建配置对象,如果在数组中已经存在同名的构建配置对象,那么直接返回不重新创建。

那直接在这里打断点调试?别急,回到CocoaPods文档,搜索这个方法,你会发现CocoaPods的Project类重写了方法:

# File 'lib/cocoapods/project.rb', line 374

def add_build_configuration(name, type)
  build_configuration = super
  settings = build_configuration.build_settings
  definitions = settings['GCC_PREPROCESSOR_DEFINITIONS'] || ['$(inherited)']
  defines = [defininition_for_build_configuration(name)]
  defines << 'DEBUG' if type == :debug
  defines.each do |define|
    value = "#{define}=1"
    unless definitions.include?(value)
      definitions.unshift(value)
    end
  end
  settings['GCC_PREPROCESSOR_DEFINITIONS'] = definitions

  if type == :debug
    settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] = 'DEBUG'
  end

  build_configuration
end

7.4. add_build_configuration的调试

开始调试前需要先搭建一个用于调试CocoaPods源码的环境,如果你没搭建过,可以参考这篇文章CocoaPods - 源码调试环境搭建。

打开CocoaPods项目源码下的lib/cocoapods/project.rb文件,找到add_build_configuration方法打上断点,运行调试:

Flutter & iOS问题记录 - 多环境配置下Pod库的宏定义失效_第8张图片

从图中可以看到,首个添加的构建配置的名称是Debug,暂时不往下继续执行,先通过调用堆栈中的Xcodeproj::Project#initialize_from_scratch ...定位add_build_configuration方法的调用位置。调用位置是在父类Xcodeproj::Project的初始化方法中,源码如下:

def initialize_from_scratch
  @archive_version =  Constants::LAST_KNOWN_ARCHIVE_VERSION.to_s
  @classes         =  {}
  
  root_object.remove_referrer(self) if root_object
  @root_object = new(PBXProject)
  root_object.add_referrer(self)
  
  root_object.main_group = new(PBXGroup)
  root_object.product_ref_group = root_object.main_group.new_group('Products')
  
  config_list = new(XCConfigurationList)
  root_object.build_configuration_list = config_list
  config_list.default_configuration_name = 'Release'
  config_list.default_configuration_is_visible = '0'
  add_build_configuration('Debug', :debug)
  add_build_configuration('Release', :release)
  
  new_group('Frameworks')
end

这下可以解释为什么Pods工程会比主工程多Debug和Release构建配置,原来在pods_project对象初始化时会默认创建。

继续调试分析,代码的作用都一一做了说明:

def add_build_configuration(name, type)
  # 调用父类的add_build_configuration方法获取构建配置对象
  build_configuration = super
  # 获取构建配置中的构建设置。settings是一个Hash对象,Hash可以看作是Map或字典
  settings = build_configuration.build_settings
  # 获取key为GCC_PREPROCESSOR_DEFINITIONS的值,如果没获取到(为nil,即为false)则将['$(inherited)']赋值给definitions
  definitions = settings['GCC_PREPROCESSOR_DEFINITIONS'] || ['$(inherited)']
  # 创建defines数组
  # defininition_for_build_configuration的定义如下:
  # def defininition_for_build_configuration(name)
  #   "POD_CONFIGURATION_#{name.underscore}".gsub(/[^a-zA-Z0-9_]/, '_').upcase
  # end
  # 作用是根据构建配置名称生成类似这样的POD_CONFIGURATION_DEBUG字符串
  defines = [defininition_for_build_configuration(name)]
  # 如果配置类型为:debug,则将DEBUG字符串插入到defines数组末尾
  # :debug是一个符号(Symbol),符号可以看作是字符串常量
  defines << 'DEBUG' if type == :debug
  # 遍历defines数组
  defines.each do |define|
    # 拼接字符串,例如将DEBUG变为DEBUG=1
    value = "#{define}=1"
    # 如果definitions数组不包含value,则将value插入到defines数组头部
    unless definitions.include?(value)
      definitions.unshift(value)
    end
  end
  # 将修改保存到构建设置
  settings['GCC_PREPROCESSOR_DEFINITIONS'] = definitions
  
  # 如果配置类型是:debug,修改SWIFT_ACTIVE_COMPILATION_CONDITIONS的值为DEBUG
  if type == :debug
    settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] = 'DEBUG'
  end
  
  # 返回构建配置对象
  build_configuration
end

调试过程中,发现一个奇怪的问题,Dev/Pre/Prod构建配置的配置类型都是:release,配置类型为:release时,settings对象中不存在GCC_OPTIMIZATION_LEVEL这个设置。如果构建设置直接复制于主工程,应该就没有这个问题了,但现在明显不是,CocoaPods很可能根据配置类型创建了新的构建设置,这引出一个新的疑问,构建配置的类型又是怎么决定的?

继续分析前,先确认一下GCC_PREPROCESSOR_DEFINITIONS是不是也有问题。开发中会涉及到这个构建设置的用法一般是在判断是否有定义DEBUG的时候,例如:

#ifdef DEBUG
#define NSLog(...) NSLog(__VA_ARGS__)
#else
#define NSLog(...)
#endif

实测Dev环境编译运行项目时DEBUG没定义,在Xcode中查看也确实是没定义DEBUG=1

Flutter & iOS问题记录 - 多环境配置下Pod库的宏定义失效_第9张图片

所以在多环境配置下,除了预定义宏__OPTIMIZE__有问题,DEBUG定义也有问题,甚至一些本篇文章没提到的也有问题。

7.5. 构建设置的来源

要确定构建设置的来源,首先要找到创建构建配置的地方,也就是前面出现过的Xcodeproj::Project类中的add_build_configuration方法。创建构建配置对象的代码及说明:

def add_build_configuration(name, type)
  build_configuration_list = root_object.build_configuration_list
  # 判断是否已经创建过
  if build_configuration = build_configuration_list[name]
    # 创建过直接返回构建配置对象
    build_configuration
  else
    # 创建构建配置对象
    build_configuration = new(XCBuildConfiguration)
    # 设置构建配置名称
    build_configuration.name = name
    # 获取默认构建设置
    # 默认构建设置按构建配置类型分三种(:all/:release/:debug),其中:all类型的构建设置是通用的
    common_settings = Constants::PROJECT_DEFAULT_BUILD_SETTINGS
    # 深拷贝一份通用构建设置给settings
    settings = ProjectHelper.deep_dup(common_settings[:all])
    # 深拷贝一份对应配置类型的构建设置合并到settings
    settings.merge!(ProjectHelper.deep_dup(common_settings[type]))
    # 设置构建设置
    build_configuration.build_settings = settings
    # 保存构建配置对象并返回
    build_configuration_list.build_configurations << build_configuration
    build_configuration
  end
end

PROJECT_DEFAULT_BUILD_SETTINGS的定义(折叠了:all类型,完整定义请看Xcodeproj::Constants):

Flutter & iOS问题记录 - 多环境配置下Pod库的宏定义失效_第10张图片

所以现在可以确定CocoaPods会根据配置类型创建新的构建设置,如果配置类型是对的,按这个方式创建的构建设置应该是没问题的。目前就剩最后一个问题,构建配置的类型是怎么决定的?

7.6. 配置类型的决定

重新运行前面的调试,跳过初始化时的两次调用add_build_configuration方法(跳过创建Debug/Release构建配置),在调用堆栈找到新的调用位置:

Flutter & iOS问题记录 - 多环境配置下Pod库的宏定义失效_第11张图片

关键在于build_configurations这个Hash对象,里面存放着构建配置名称和类型的映射关系。通过不断打断点和利用调用堆栈,一直追寻build_configurations,终于找到了build_configurations初始化的地方。TargetInspector类(位于lib/cocoapods/installer/analyzer/target_inspector.rb)中的compute_results方法:

def compute_results(user_project)
  raise ArgumentError, 'Cannot compute results without a user project set' unless user_project
  
  targets = compute_targets(user_project)
  project_target_uuids = targets.map(&:uuid)
  build_configurations = compute_build_configurations(targets)
  platform = compute_platform(targets)
  archs = compute_archs(targets)
  swift_version = compute_swift_version_from_targets(targets)
  
  result = TargetInspectionResult.new(target_definition, user_project, project_target_uuids,
                                      build_configurations, platform, archs)
                                      result.target_definition.swift_version = swift_version
                                      result
end

位于同文件内的compute_build_configurations方法:

def compute_build_configurations(user_targets)
  if user_targets
    user_targets.flat_map { |t| t.build_configurations.map(&:name) }.each_with_object({}) do |name, hash|
      hash[name] = name == 'Debug' ? :debug : :release
    end.merge(target_definition.build_configurations || {})
  else
    target_definition.build_configurations || {}
  end
end

观察compute_build_configurations方法可以发现,里面最关键的代码应该是:

hash[name] = name == 'Debug' ? :debug : :release

原来只有当构建配置的名称为Debug时,才会被设置为:debug配置类型,其余均默认为:release配置类型。

问题的根源总算找到了,那能从根源上解决该问题吗?直接修改CocoaPods源码肯定是不靠谱的,应该还有其他办法。

7.7. 配置类型的设置

compute_build_configurations方法继续分析,这段代码:

user_targets.flat_map { |t| t.build_configurations.map(&:name) }.each_with_object({}) do |name, hash|
  hash[name] = name == 'Debug' ? :debug : :release
end.merge(target_definition.build_configurations || {})

等价于:

build_configurations = user_targets.flat_map { |t| t.build_configurations.map(&:name) }.each_with_object({}) do |name, hash|
  hash[name] = name == 'Debug' ? :debug : :release
end
build_configurations.merge(target_definition.build_configurations || {})

user_targets...end返回的Hash对象调用了合并方法,如果target_definition.build_configurationsnil,则合并一个空的Hash对象{}。如果key重复了,合并方法会用新的value覆盖原来的value。如果有方法能设置target_definition.build_configurations的值,那问题是不是就从根源上解决了?

通过单步调试,来到了TargetDefinition类(位于lib/cocoapods-core/podfile/target_definition.rb文件)中的build_configurations方法:

def build_configurations
  if root?
    get_hash_value('build_configurations')
  else
    get_hash_value('build_configurations') || parent.build_configurations
  end
end

get_hash_value的定义:

def get_hash_value(key, base_value = nil)
  # 检查key,不支持的key会抛异常
  # HASH_KEYS是一个字符串数组,里面包含build_configurations
  unless HASH_KEYS.include?(key)
    raise StandardError, "Unsupported hash key `#{key}`"
  end
  # 如果key对应的value为nil,则将base_value赋值给internal_hash[key]
  # .nil?方法用于判断对象是否存在
  internal_hash[key] = base_value if internal_hash[key].nil?
  # 返回key对应的value
  internal_hash[key]
end

看来internal_hash是关键,从调试结果来看,get_hash_value返回值一直是nil。搜索target_definition.rb文件找到internal_hash初始化位置:

def initialize(name, parent, internal_hash = nil)
  @internal_hash = internal_hash || {}
  @parent = parent
  @children = []
  @label = nil
  self.name ||= name
  # 如果parent是一个TargetDefinition对象,则将当前TargetDefinition对象存入到parent的children数组
  if parent.is_a?(TargetDefinition)
    parent.children << self
  end
end

internal_hash变量名前面加个@表示这是一个实例变量。在这个构造方法中打上断点,重新运行调试。首次进入断点,internal_hash是nil,通过调用堆栈找到调用的地方,来到了Podfile类的构造方法:

Flutter & iOS问题记录 - 多环境配置下Pod库的宏定义失效_第12张图片

构造方法中默认创建了名为PodsTargetDefinition对象(后面用Pods-TargetDefinition指代该对象),并将该对象设为current_target_definition。接着执行instance_eval(&block),猜猜这是用来干什么的?继续单步调试下去,最终会发现来到了Xcode项目里面的Podfile文件:

Flutter & iOS问题记录 - 多环境配置下Pod库的宏定义失效_第13张图片

继续调试,来到了DSL模块(位于lib/cocoapods-core/podfile/dsl.rb)中的target方法:

def target(name, options = nil)
  if options
    raise Informative, "Unsupported options `#{options}` for " \
    "target `#{name}`."
  end
  
  parent = current_target_definition
  definition = TargetDefinition.new(name, parent)
  self.current_target_definition = definition
  yield if block_given?
ensure
  self.current_target_definition = parent
end

原来Podfile文件中的target配置是方法调用(其实不止这个,在Podfile文件中的配置都能在DSL模块中找到对应的方法),每调用一次都会创建一个TargetDefinition对象。如果target方法后面有代码块(do...end),那么block_given?将为true,同时yield关键字的作用是调用代码块,所以yield if block_given?这行代码的作用就是如果存在代码块就调用。

单步调试进入到target方法的代码块中,use_frameworks!也是一个方法,同样位于DSL模块:

def use_frameworks!(option = true)
  current_target_definition.use_frameworks!(option)
end

current_target_definition调用的use_frameworks!TargetDefinition类中的方法:

def use_frameworks!(option = true)
  value = case option
    when true, false
    option ? BuildType.dynamic_framework : BuildType.static_library
    when Hash
    BuildType.new(:linkage => option.fetch(:linkage), :packaging => :framework)
    else
    raise ArgumentError, "Got `#{option.inspect}`, should be a boolean or hash."
  end
  set_hash_value('uses_frameworks', value.to_hash)
end

最终通过set_hash_value方法完成了设置:

def set_hash_value(key, value)
  unless HASH_KEYS.include?(key)
    raise StandardError, "Unsupported hash key `#{key}`"
  end
  internal_hash[key] = value
end

分析到这,我突然想起来前面好像没有确认TargetDefinition类中有没有设置build_configurations的方法,搜索一番,找到了这个:

def build_configurations=(hash)
  set_hash_value('build_configurations', hash) unless hash.empty?
end

Ruby方法名后面加=是很常见的做法,这样做的好处是,可以像变量赋值那样(build_configurations = xxx)调用方法。

经过前面的调试,大概可以猜到,build_configurations=方法调用的地方应该和use_frameworks!方法一样,都在DSL模块中。在DSL模块中搜索build_configurations,找到这个:

def project(path, build_configurations = {})
  current_target_definition.user_project_path = path
  current_target_definition.build_configurations = build_configurations
end

竟然是project配置!众里寻他千百度。蓦然回首,那人却在,灯火阑珊处。

7.8. project配置的使用

如果你看过Flutter项目中的iOS部分,那应该在Podfile文件中看到过这个:

project 'Runner', {
  'Debug' => :debug,
  'Profile' => :release,
  'Release' => :release,
}

Flutter创建项目时已经默认帮你设置好了构建配置的类型,其中Runner是Xcode项目的名称。参照这个,在用于测试的Xcode项目中加上project配置:

project 'app', {
  'Dev' => :debug,
  'Pre' => :release,
  'Prod' => :release,
}

重新执行pod install命令,不管是GCC_OPTIMIZATION_LEVEL,还是GCC_PREPROCESSOR_DEFINITIONS,一切正常!如果想了解更多关于project配置的使用,请看CocoaPods-Core文档。

如果你看了CocoaPods-Core文档中的示例,不知道有没有这样一个疑问,为什么示例中的project配置放在了target方法的代码块里面,这和放在外面有什么区别吗?

# This Target can be found in a Xcode project called `FastGPS`
target 'MyGPSApp' do
  project 'FastGPS'
  ...
end

# Same Podfile, multiple Xcodeprojects
target 'MyNotesApp' do
  project 'FastNotes'
  ...
end

7.9. Podfile配置的作用范围

根据前面的分析,可以知道Podfile的配置实际是调用DSL模块中的方法,进而配置current_target_definition指向的TargetDefinition对象。结合断点调试,current_target_definition变化如下:

  1. 首先是Podfile对象初始化时默认创建的Pods-TargetDefinition
def initialize(defined_in_file = nil, internal_hash = {}, &block)
  self.defined_in_file = defined_in_file
  @internal_hash = internal_hash
  if block
    # self是Podfile对象,Pods-TargetDefinition的parent指向了Podfile对象
    default_target_def = TargetDefinition.new('Pods', self)
    # 设置为抽象目标,这意味着Pods工程不会引入该target(在TARGETS列表中不会有这个target)
    # 抽象目标的好处体现在作用范围上,继续看下去就明白了
    default_target_def.abstract = true
    @root_target_definitions = [default_target_def]
    # current_target_definition实例变量初始化
    @current_target_definition = default_target_def
    instance_eval(&block)
  else
    @root_target_definitions = []
  end
end
  1. 然后是执行到target方法时,会临时指向新创建的TargetDefinition对象,方法执行结束后恢复原来的指向
def target(name, options = nil)
  if options
    raise Informative, "Unsupported options `#{options}` for " \
    "target `#{name}`."
  end
  
  # 如果是嵌套的target方法调用,parent指向的是上一层的TargetDefinition对象,反之指向的是名为Pods的TargetDefinition对象
  parent = current_target_definition
  definition = TargetDefinition.new(name, parent)
  # 执行代码块前设置为刚创建的TargetDefinition对象,如果代码块内有嵌套的target方法,parent将为刚创建的TargetDefinition对象
  self.current_target_definition = definition
  yield if block_given?
# ensure关键字的作用是确保后面的代码一定会执行,不会因为前面抛异常而终止(执行代码块可能会抛异常)
ensure
  # 恢复到target方法执行前指向的对象
  self.current_target_definition = parent
end

根据以上两点变化,我们可以知道,在Podfile文件中,如果project配置放在target代码块外面(不管在target配置的前面还是后面),构建配置的类型将会设置到Pods-TargetDefinition,作用范围将会是全部的target配置;如果放在target代码块里面,构建配置的类型将会设置到当前的TargetDefinition对象,作用范围只限于当前的target配置及嵌套的子target配置。

再继续简单补充一些内容方便理解。project配置的build_configurations对应的获取方法是这样的:

def build_configurations
  if root?
    get_hash_value('build_configurations')
  else
    get_hash_value('build_configurations') || parent.build_configurations
  end
end

代码里出现的root?是一个方法:

def root?
  parent.is_a?(Podfile) || parent.nil?
end

这方法用来判断当前是否为根TargetDefinition对象,判断条件很简单,如果parent指向了Podfile对象或者parentnil,那当前就是根TargetDefinition对象。那么显而易见,初始化时parent参数传入Podfile对象的Pods TargetDefinition就是一个TargetDefinition对象。

所以如果当前get_hash_value('build_configurations')返回nil,会继续调用parentbuild_configurations方法获取,就这样一级一级找上去,最终来到根TargetDefinition对象Pods-TargetDefinitionbuild_configurations方法,这时还获取不到那就是真获取不到。

关于抽象目标的作用,从前面的分析不难看出,能限定Podfile配置的作用范围。CocoaPods也提供了专门的配置(abstract_target)用于创建抽象目标,配置对应的具体方法还是在DSL模块中:

def abstract_target(name)
  target(name) do
    abstract!
    yield if block_given?
  end
end

def abstract!(abstract = true)
  current_target_definition.abstract = abstract
end

如果你想在Podfile文件中让两个target共用一些配置,可以这样做:

# 名称随意取
abstract_target 'Shows' do
  # 两个target都依赖这个库
  pod 'ShowsKit'

  target 'ShowsiOS' do
    pod 'ShowWebAuth'
  end

  target 'ShowsTV' do
    pod 'ShowTVAuth'
  end
end

以上示例来自CocoaPods-Core文档,有所删减改动。

pod配置的依赖对应的获取方法:

def dependencies
  if exclusive?
    non_inherited_dependencies
  else
    # non_inherited_dependencies:当前target中的依赖数组
    # parent.dependencies:调用parent的dependencies方法获取继承的依赖数组
    non_inherited_dependencies + parent.dependencies
  end
end

解决方案

在Xcode项目的Podfile文件中加入以下配置:

project '【Xcode项目名称】', {
  '【Degbug环境的配置名称】' => :debug,
  '【Release环境的配置名称】' => :release,
}

按项目的实际情况填写并替换【...名称】,映射不限个数,实际有多少个环境配置就写多少个。

例如有一个叫app的Xcode项目有三个环境配置,配置名称分别是Dev、Pre和Prod,其中Dev属于Debug环境,Pre和Prod属于Release环境,那么需要加入的配置是这样的:

project 'app', {
  'Dev' => :debug,
  'Pre' => :release,
  'Prod' => :release,
}

当然,如果你有看前面的问题分析,就会知道这配置还可以继续精简。因为自定义的环境配置全部都会被默认映射为:release类型,所以可以精简为:

project 'app', {
  'Dev' => :debug,
}

总结

本篇文章中的多环境配置是通过自定义构建配置实现的,多环境配置不止这一种方式,还可以通过多Target实现,不过如果你是通过多Target实现的多环境应该不会碰到这个问题,前提是你没自定义构建配置(保持默认的Debug/Release配置)。文中出现了比较多的Ruby代码,重要的部分我都尽量做了说明,如果你不熟悉Ruby语法,建议亲自上手调试CocoaPods源码,关于调试环境的搭建请看这篇文章CocoaPods - 源码调试环境搭建。

断断续续写了几个晚上终于写完啦!本来分析到自动修正Pods工程的优化编译设置时就已经可以快速把这个问题解决了,但是后来想了想,没找到根本原因治标不治本还是差点意思,结果没想到随着分析深入越写越多,涉及到的东西越来越多。虽然耗费的精力有点多,但是获益良多。

最后

如果这篇文章对你有所帮助,请不要吝啬你的点赞加星,谢谢~

你可能感兴趣的:(问题记录,Flutter,iOS,flutter,ios,cocoapods)