58同城iOS混编项目无用代码检测方案介绍

7,030 阅读15分钟

摘要:本文主要介绍如何通过对Mach-O文件的解析以及反汇编的应用实现OC&Swift的无用代码检测,重点介绍Swift的检测方案。本文作为Swift Mach-O的应用篇,建议先阅读《从Mach-O角度谈谈Swift和OC的存储差异》《Swift Hook新思路--虚函数表》了解相关概念和结构。相关代码已经开源:WBBlades,如果感觉工具或方案对您有帮助不妨帮忙点个star。

背景

近期很多大型APP都在做支持Swift与Objective-C的混编开发的工作,58集团旗下的各个APP也在积极探索使用Swift语言开发。因此可以预见,在未来的几年里集团内各个iOS项目中Swift代码的占比会越来越高。因此我们需要考虑Swift代码激增后所带来的一些问题。如何检测混编项目中无用代码是我们面临的诸多问题之一。

关于无用代码检测

无用代码需不需要检测?需不需要删除?无用代码删除在所有的性能优化手段里基本上是ROI最低的。但是几乎所有ROI较高的技术手段都是一次性优化方案,经过几个版本迭代后再做优化就会比较乏力。相比之下,针对代码的检测和删除在很长的一段时间内提供了很大的优化空间。我们以58APP的10.15.1版本为例,iPhone 7设备上的App Store正式包中主二进制文件的大小占APP包大小的66%,动态库占15%,而资源占比不足20%。 图片2.png

在越狱设备上获取从App Store下载的包可以准确查看当台设备上的包构成(个人认为这是最准确的测算方式)。58APP的资源占比较小是因为我们主要使用xcassert存储图片,这可以充分利用分片下发的能力。如果你的图片存储依旧使用bundle存储,那么可能资源的比例会相对高一些,在这种情况下建议先将资源转存到xcassert。

除了包大小优化外,及时删除无用代码对启动优化也有一定的帮助。另外,无用代码检测和删除在项目维护上起到了很重要的作用。冗余的代码往往意味着开发者需要更多额外的精力来评估需求的影响范围,及时删除废弃的代码可以从一定程度上提升开发效率。无用代码的静态检测并不是要将项目中的所有无用代码都检测出来,而是能为后续的检测流程在庞大的源码库中提供一个圈选能力。因此静态检测需要提供相当数量的疑似无用代码集合,并且在这个集合中无用代码的比例应该尽可能的高。静态检测的准确度有限,并不能作为单一手段,因此只能起到前置过滤的作用。在58同城中,除了WBBlades检测外,还有根据业务代码特征的二次过滤以及运行时判断等手段。

混编项目无用代码检测的几种手段

在OC开发环境中,无用代码的检测方案比较多,但是OC&Swift混编环境的无用代码检测方案相对较少。原因是OC与Swift无论是在编译前端还是编译后的二进制文件上,都存在较大的差异。这就导致OC的检测方案不一定适用于Swift,而Swift的检测方案也不一定适用于OC。目前业界常用的技术手段包括AppCode工具检测以及以例如Pecker这样的基于 IndexStoreDB SwiftSyntax的静态检测方案,通过SwiftSyntax获取所有符号并通过IndexStoreDB获取符号之间的索引关系,从而确定哪些代码之间的引用关系,得到无用代码集合。当然除了这两种技术方案外,还有很多其他的方案,例如:源码文本分析、针对framework目标文件优化的技术方案、基于Mach-O文件分析的技术等等。至于选择哪种技术方案主要取决于当前的工具在什么场景下使用,甚至不同代码量的APP最优方案也不同。58APP在接入Swift语言之前就已经确定了基于Mach-O分析的无用代码检测方案,这主要是该方案比较容易接入到版本流程中。因此,为了保持技术方案的统一,在项目混编后我们依旧采用基于Mach-O文件分析的方式来实现无用代码检测。

OC是如何实现无用代码检测的?

OC的无用代码检测和优化方案有很多种,优化方案遍布编译、链接、Product、运行等各个阶段。58同城采用的是对Product的扫描以及运行时核验的双重保障机制。其中对Product的扫描就是通过WBBlades扫描Mach-O文件来实现的。基本思路就是对classlist和classrefs做差集,形成初步的无用类集合,并根据业务代码特征做二次适配。例如:作为基类或者成员变量的类、通过完整字符串实现动态调用的类、RN或者Hybrid的Module通过load方法注册的类等都会被当做有用的代码,不会出现在无用代码集合中,减少了二次核验的成本。但是此套方案无法直接应用与Swift语言开发的项目,接下来我们来探讨下原因及解决方案。

Swfit的类调用

在OC的检测方案中,很大程度上是依赖classlist和classrefs做差集来实现的。其他技术手段不过是作为补充技术手段。如果没有classrefs这样一个section为我们提供主要信息,那么整个方案的技术基础就会受到动摇。那我们首先要弄清楚类如何使用会被存储到classrefs中。首先我们来看个示例:

WBBladesClass *b = nil; 
id c = [WBBladesClass new];
Class d = NSClassFromString(@"WBBladesClass");

上面示例中只有通过[WBBladesClass new]显式的方法调用时,OC的类才会被存储classrefs中。

那Swift的类是不是也存在这样的特性呢?

在Swift调用环境中,被显式调用的类并不会被加入的classrefs这个section中。下面的代码经过编译链接后,查看MachOView发现TestClass0和TestClass1这两个类并不在classrefs中。

class TestClass0: NSObject {
    dynamic func hello() {
        let obj = TestClass0.init()
    }
}
class TestClass1 {
    func hello() {
        let obj = TestClass1.init()
    }
}

但是,如果类被导出到OC环境中使用,那么这个Swift类就会被加入到classrefs中。

class TestClass2 : NSObject{}
//在OC环境中调用则会被加入到classrefs中
+ (void)load{
    id obj = [TestClass2 new];
}

只有TestClass2被加入到classrefs 因此可以说明classrefs只适用于OC的语言环境,即使刨除Struct、enum等类型不谈,classlist和classrefs做差集的方案也不适用于Swift的无用代码检测。

那如何才能识别出来一个Swift类型被调用呢?

那么问题来了,如果没有classrefs做记录,如何才能知道一个Swift的类被使用了呢?之前我们在《从Mach-O角度谈谈Swift和OC的存储差异》和 《Swift Hook新思路--虚函数表》中详细介绍了Swift的类的存储结构。

struct ClassContextDescriptor{
    uint32_t Flag;
    uint32_t Parent;
    int32_t  Name;
    int32_t  AccessFunction;
    int32_t  FieldDescriptor;
    int32_t  SuperclassType;
    uint32_t MetadataNegativeSizeInWords;
    uint32_t MetadataPositiveSizeInWords;
    uint32_t NumImmediateMembers;
    uint32_t NumFields;
    uint32_t FieldOffsetVectorOffset;
    <泛型签名> //字节数与泛型的参数和约束数量有关
    <MaybeAddResilientSuperclass>//有则添加4字节
    <MaybeAddMetadataInitialization>//有则添加4*3字节
    VTableList[]//先用4字节存储offset/pointerSize,再用4字节描述数量,随后N个4+4字节描述函数类型及函数地址。
    OverrideTableList[]//先用4字节描述数量,随后N个4+4+4字节描述当前被重写的类、被重写的函数描述、当前重写函数地址。
}

在这里可能有同学会有疑问,上述结构与调试时的结构不相符。调试时,Swift的类的结构应该如下所示:

struct SwiftMetadataClass {
    NSInteger kind;
    id superclass;
    NSInteger reserveword1;
    NSInteger reserveword2;
    NSUInteger rodataPointer;
    UInt32 classFlags;
    UInt32 instanceAddressPoint;
    UInt32 instanceSize;
    UInt16 instanceAlignmentMask;
    UInt16 runtimeReservedField;
    UInt32 classObjectSize;
    UInt32 classObjectAddressPoint;
    NSInteger nominalTypeDescriptor;
    NSInteger ivarDestroyer;
    ...//N个函数地址
};

在runtime中我们通过类.self获取到的是struct SwiftMetadataClass ,而我们提到存储结构指的是ClassContextDescriptor,两者并不是一个结构。

//通过这样的强制转换能清楚的发现TestClass2的supperclass等信息
struct SwiftMetadataClass* swiftClass = 
(__bridge struct SwiftMetadataClass * )(TestClass2.self);

SwiftMetadataClass是Swift的运行态数据,在Mach-O文件中,类的SwiftMetadataClass结构体存储在DATA段中。细心的同学会发现,Swift的Mach-O相比OC多了一个名为(__ TEXT,__const)的section。这个section中存储的就是Swift的TypeContextDescriptorClassContextDescriptor的父类)结构。TypeContextDescriptorSwiftMetadataClass而言更接近源码形态,通过TypeContextDescriptor我们能很容易知道这个代码在哪个Module中定义的、有多少属性、属性都是什么类型、是否是泛型、有多少函数、又有哪些函数是重写父类的等等。但是由于MachOView并没有很好地适配好Swift的Mach-O文件,我们看到的这个section是未经格式化展示的二进制数据。

ClassContextDescriptor和SwiftMetadataClass又有什么关系呢?

简而言之SwiftMetadataClass.nominalTypeDescriptor指向的就是这个类的ClassContextDescriptor,而ClassContextDescriptor则是通过ClassContextDescriptor.AccessFunction的函数调用获取到对应的SwiftMetadataClass地址。

 let tclass = TestClass1.self

断点查看执行就会发现,在获取到类地址之前,总会先调用TestClass1的metadata accessor函数(其实就是TestClass1的ClassContextDescriptor.AccessFunction

bl  0x100a3b32c ; type metadata accessor for BBB.TestClass1

这就意味着,我们只要在汇编代码中找到某个类的AccessFunction被调用了也就知道这个类是被使用的类。

如何在汇编代码中查找AccessFunction?

在Mach-O文件中,代码都是以机器指令的形式存储的,我们不能直接获取到助记符和操作数。因此需要借助反汇编库进行反汇编操作,将指令转为汇编代码。有了汇编代码,我们只要在每个函数的指令区间内查找是否有某个类的AccessFunction地址,就能知道这个函数中是否调用了某个类。

怎么才能知道每个函数的函数指令区间?

这一步很简单,在Debug模式的Mach-O文件中,符号表会告诉我们每个符号对应的地址。函数作为符号的一种,当然符号表也记录了函数的地址,即下面结构体中的n_value。

/*
 * This is the symbol table entry structure for 64-bit architectures.
 */
struct nlist_64 {
    union {
        uint32_t  n_strx; /* index into the string table */
    } n_un;
    uint8_t n_type;        /* type flag, see below */
    uint8_t n_sect;        /* section number or NO_SECT */
    uint16_t n_desc;       /* see <mach-o/stab.h> */
    uint64_t n_value;      /* value of this symbol (or stab offset) */
};

但是每个符号只知道这个符号的名字以及符号的起始地址。以函数为例,函数通过符号表只能知道函数名以及函数的起始地址,虽然可以通过静态分析ret指令来粗略地判断函数结尾,但在Swift的汇编代码中这种方式存在较大的偏差。因此需要换种方案,采用较为直接的方式,借助符号表将汇编指令切割分段来实现函数指令区间的判断,这也是WBBlades需要分析Debug包的原因之一。 未命名2.001.jpeg 具体做法是,首先对符号表按地址进行排序,然后把下个符号的起始地址当做当前函数的截止点。这样就实现了函数指令区间的切割。

遇到的有意思的问题

在为WBBlades做Swift适配时发现了很多有意思的问题,也是开发过程中踩到的一系列坑。

  • section判断不严谨。

之前字节跳动发过一篇文章《今日头条优化实践: iOS 包大小二进制优化,一行代码减少 60 MB 下载大小》,可能有些APP做了section迁移。如果APP做了section迁移的话,原本处于一个段的两个section变成了不同segment中的两个section。由于不同的段的base address可能不同,因此一旦地址计算出现跨段的时候,都需要做base address地址修正,否则文件的偏移地址可能会取错。另外,由于段名可能存在自定义的情况,因此也不能通过段名+节名的方式来确定唯一的一个section。需要通过段的权限+节名来确定section。

if ((segmentCommand.maxprot & (VM_PROT_WRITE | VM_PROT_READ)) ==
 (VM_PROT_WRITE | VM_PROT_READ)) {
    //能够具有读写权限的的段,即可认为为__DATA,__CONST_DATA,__AUTH_CONST等
 }

当然,这种判断方式也不是完全准确,因为section迁移后,新增的段默认是读写权限,这也意味着原先的TEXT中的数据,迁移后可能变成了VM_PROT_WRITE | VM_PROT_READ。这也是段迁移后需要重新设置权限的原因。

  • 获取类名循环遍历Parent可能发生异常
//类似这样的代码(Type的Parent可能不属于Type)
func extensions(of value: Any) {
  struct Extensions : AnyExtensions {}
   return
}

Swift与OC有个区别就是在Swift中很多地方都可以定义类或结构体。例如上面的代码中,就是在一个函数中定义了一个结构体。这时在遍历Extensions这个结构体时需要注意,它的Parent并不是一个Model Type类型,因此需要在套用结构体解析二进制的时候需要判断处理下。

  • 复杂的泛型结构

之所以说泛型复杂是因为泛型的签名是不定长的数据。它取决于泛型的参数格式和条件个数。泛型到底占多少字节,可以参考下面的布局说明。

内容字节数备注
addMetadataInstantiationCache4Bclass only
addMetadataInstantiationPattern4Bclass only
GenericParamCount2B
GenericRequirementCount2B
GenericKeyArgumentCount2B
GenericExtraArgumentCount2B
paramsGenericParamCount
pandding(unsigned)-GenericParamCount & 3填补,4字节对齐
EachParam3 * 4 * GenericRequirementCount
  • Anonymous布局

Anonymous官方解释如下

/// This context descriptor represents an anonymous possibly-generic context
/// such as a function body.
Anonymous = 2,

与类、结构体等布局不同,Anonymous在二进制中的布局如下:

Flag(4Byte) + Parent(4Byte) + 泛型签名(不定长)+ mangleName(4Byte)

但是Anonymous 不一定会存在mangleName,因此在解析Anonymous 还需要判断是否存在 mangleName。

/// Flags for anonymous type context descriptors. These values are used as the
/// kindSpecificFlags of the ContextDescriptorFlags for the anonymous context.
class AnonymousContextDescriptorFlags : public FlagSet<uint16_t> {
  enum {
    /// Whether this anonymous context descriptor is followed by its
    /// mangled name, which can be used to match the descriptor at runtime.
    HasMangledName = 0,
  };
...
};

如果此时TypeContext为Anonymous,那么需要查看Flag的前2字节是否为0。如果为0则Anonymous 没有mangleName。

  • 其他

在Swift中通过fileprivate、open等修饰的代码,都会多少些许不同,我们在开源代码中都做了适配处理。另外,有些时候Swift访问并不是通过AccessFunc,而是直接访问类的地址。这种情况一般会在符号表中存在demangling cache variable for type metadata for开头的符号。

支持范围

WBBlades做二进制扫描检测时,对APP中包含以下情况的代码作了测试。示例中的✅ 的代码能被识别为被使用到。其中V1.1是在适配Swift二进制之前,V2.0是经过适配之后。 能否被识别为有用

使用方法

  • 需要检测的APP需要在Debug环境下打出一个arm64真机包。
  • 编译WBBlades,生成WBBlades可执行文件。github.com/wuba/WBBlad…
  • 将WBBlades可执行文件拖入系统终端,并输入-unused ,再将真机包拖入终端。
  • Enter,等待几分钟,会在桌面输出结果文件。如果Swift代码较多,可能耗时较长。

应用情况及展望

目前58同城APP中大概存在2w+个类,1k+个Swift的类型定义。静态检测后发现OC代码的无用代码比例在8%左右,Swift代码的无用代码比例相对较低,大概在2%左右。通过人工复核后我们发现部分业务线的代码检测准确度较高,准确率80%+,而部分业务线的筛查结果准确度较低。造成准确率降低的主要原因是多个字符串拼接成类名进行动态调用以及在Swift中使用反射导致,这种情况如果不知道代码的拼接规则很难通过通用的手段来检测。后续我们会逐渐完善工具,在输出扫描结果的同时给出每个无用代码在二进制文件中的字节数,方便开发者做决策使用。

总结

Swift是一门非常神奇且深奥的语言,上层使用灵活是以底层复杂适配为代价的。笔者也是在逐渐摸索和学习中,因此可能难免带着OC的思维来看Swift这门语言,例如在工具开发之前笔者一直考虑的是如何检测Swift无用类,但是实际上Struct、Enum等类型等在开发中同样重要。因此,WBBlades还在持续优化。目前WBBldes得到了58集团内外共14个团队或个人的协助,正在持续的体验和收集问题。如果您有好的想法或者问题,可以在GitHub上留言沟通。

作者介绍

邓竹立:用户价值增长中心-平台技术部-iOS技术部 资深开发工程师,WBBlades开源工具作者

参考文献

developer.apple.com/documentati…

github.com/apple/swift…

www.jianshu.com/p/158574ab8…

www.jianshu.com/p/ef0ff6ee6… mp.weixin.qq.com/s/egrQxxJSy…

github.com/alibaba/Han…

www.jianshu.com/p/0cbbbe783…

juejin.cn/post/693976…

github.com/apple/swift…

juejin.cn/post/691112…

github.com/woshiccm/Pe…