关于ios:云音乐iOS端代码静态检测实践

2次阅读

共计 11259 个字符,预计需要花费 29 分钟才能阅读完成。

图片来自:https://unsplash.com
本文作者:尘心

一、前言

随着我的项目的扩充,依附纯人工 Code Review 来保障代码品质、避免代码劣化变得”力不从心“。此时有必要借助代码动态剖析能力,晋升我的项目可继续倒退所须要的自动化程度。针对 C、Objective-C 支流的动态剖析开源我的项目包含:Clang Static Analyzer、Infer、OCLint 等。它们各自特点如下:

联合以上剖析和对理论利用中可定制性的强烈诉求,最终咱们抉择了可定制性最强的 OCLint 作为代码动态检测工具。接下来将从以下四点介绍 OCLint 的实际利用过程:

  1. OCLint 环境部署、编译和剖析。
  2. 自定义规定实现。
  3. 动态检测耗时优化。
  4. 利用动态检测能力继续对启动性能防劣化管制。

二、OCLint 简介

下面有对 OCLint 做一个简略介绍,具体来看其总体构造如下:

Core Module:是 OCLint 的引擎。它会将工作按程序调配给其余模块,驱动整个剖析过程,并生成输入报告。

Metrics Module:是一个独立的库。这个模块实际上不依赖于任何其余 OCLint 模块。意思是咱们也能够在其余代码检测我的项目中独自应用这个模块。

Rules Module:OCLint 是一个基于规定的工具。规定就是动静库,能够在运行时轻松加载到零碎中,基于此 OCLint 领有很强的可扩展性。此外,通过遵循开 / 闭准则,OCLint 可通过动静加载扩大规定而不必批改或从新编译本身,所有规定都作为 RuleBase 的子类实现。

Reporters Module:在剖析实现后,对于检测到的每一个问题,咱们都晓得节点的详细信息、规定、诊断信息。Reporters 将获取这些信息,并将其转换为可读的报告。

三、环境部署

3.1 OCLint

brew tap oclint/formulae
brew install oclint

上述办法是官网举荐,但装置的版本并不是最新的,这里倡议应用 brew install --cask oclint 装置最新版本。

3.2 xcpretty

是一个格式化 xcodebuild 输入的工具。

gem install xcpretty

四、输入编译产物

环境装置好后,接下来就能够 clone 工程,筹备好 全源码 编译环境。通过 xcodebuild 与 xcpretty 格式化输入编译产物。

在工程目录下通过终端执行:

xcodebuild -workspace "${project_name}.xcworkspace" -scheme ${scheme} -destination generic/platform=iOS -configuration Debug COMPILER_INDEX_STORE_ENABLE=NO | xcpretty -r json-compilation-database -o compile_commands.json

五、Clang 简介

因为 OCLint 基于 Clang Tooling,能够简略的了解为对 Clang Tooling 做了一层封装,其外围能力是对 Clang AST 进行剖析,统计出所有违反规定的代码信息,并输入剖析报告。所以在应用 OCLint 做动态剖析之前,了解 Clang 将大有裨益。

既然外围能力是剖析 Clang AST,那么 Clang AST 到底是什么样子的,让咱们一起来看看。

5.1 Clang AST

Clang AST 是编译前端的两头产物,产生在词法剖析之后的语法分析阶段。一个 AST 节点示意申明、语句、类型,因而,有三个示意 AST 的外围类:Decl、Stmt、Type。在 Clang 中,每个语言构造都必须继承上述外围类之一。

让咱们来看一个简略的 AST 示例:

#include "test.hpp"

int f(int x) {int result = (x / 42);
  return result;
}

在工程目录下执行 clang -Xclang -ast-dump -fsyntax-only test.cpp,输入 AST:

TranslationUnitDecl 0x7f7cb3040408 <<invalid sloc>> <invalid sloc>
|-TypedefDecl 0x7f7cb3040c70 <<invalid sloc>> <invalid sloc> implicit __int128_t '__int128'
| `-BuiltinType 0x7f7cb30409d0 '__int128'
...
`-FunctionDecl 0x7f7cb4823f78 <test.cpp:3:1, line:6:1> line:3:5 f 'int (int)'
  |-ParmVarDecl 0x7f7cb4823ee0 <col:7, col:11> col:11 used x 'int'
  `-CompoundStmt 0x7f7cb4824198 <col:14, line:6:1>
    |-DeclStmt 0x7f7cb4824138 <line:4:3, col:24>
    | `-VarDecl 0x7f7cb4824038 <col:3, col:23> col:7 used result 'int' cinit
    |   `-ParenExpr 0x7f7cb4824118 <col:16, col:23> 'int'
    |     `-BinaryOperator 0x7f7cb48240f8 <col:17, col:21> 'int' '/'
    |       |-ImplicitCastExpr 0x7f7cb48240e0 <col:17> 'int' <LValueToRValue>
    |       | `-DeclRefExpr 0x7f7cb48240a0 <col:17> 'int' lvalue ParmVar 0x7f7cb4823ee0 'x' 'int'
    |       `-IntegerLiteral 0x7f7cb48240c0 <col:21> 'int' 42
    `-ReturnStmt 0x7f7cb4824188 <line:5:3, col:10>
      `-ImplicitCastExpr 0x7f7cb4824170 <col:10> 'int' <LValueToRValue>
        `-DeclRefExpr 0x7f7cb4824150 <col:10> 'int' lvalue Var 0x7f7cb4824038 'result' 'int'

顶层的 AST 节点是 TranslationUnitDecl。它是其它所有 AST 节点的根,代表整个翻译单元。FunctionDecl 是函数申明,CompoundStmt 蕴含了其余的语句和表达式。
下图是它的 AST 的图形视图:

5.2 遍历解析 Clang AST

这里咱们能够通过官网教程《How to write RecursiveASTVisitor based ASTFrontendActions》来理解这一过程。内容很具体,就不过多赘述了,大抵流程如下图:

六、OCLint 代码动态剖析与输入

6.1 OCLint 如何工作?

下面咱们拿到了编译产物 compile_commands.json 文件,并简略理解了 Clang AST 的遍历解析过程,那 OCLint 是如何工作的呢?咱们无妨从 OCLint 源码动手,窥探一二。

上面是 oclint/oclint-driver/main.cpp 入口文件的 main() 函数:

int main(int argc, const char **argv)
{llvm::cl::SetVersionPrinter(oclintVersionPrinter);
    // 结构 parser
    auto expectedParser = CommonOptionsParser::create(argc, argv, OCLintOptionCategory);
    if (!expectedParser)
    {llvm::errs() << expectedParser.takeError();
        return COMMON_OPTIONS_PARSER_ERRORS;
    }
    CommonOptionsParser &optionsParser = expectedParser.get();
    oclint::option::process(argv[0]);

    // 筹备工作  查看 rule & reporter
    int prepareStatus = prepare();
    if (prepareStatus)
    {return prepareStatus;}

    // 筛选 rule
    if (oclint::option::showEnabledRules())
    {listRules();
    }

    // 结构 analyzer & driver
    oclint::RulesetBasedAnalyzer analyzer(oclint::option::rulesetFilter().filteredRules());
    oclint::Driver driver;

    // 开始剖析
    try
    {driver.run(optionsParser.getCompilations(), optionsParser.getSourcePathList(), analyzer);
    }
    catch (const exception& e)
    {printErrorLine(e.what());
        return ERROR_WHILE_PROCESSING;
    }

    // 失去剖析后果 & 输入报告
    std::unique_ptr<oclint::Results> results(std::move(getResults()));

    try
    {ostream *out = outStream();
        reporter()->report(results.get(), *out);
        disposeOutStream(out);
    }
    catch (const exception& e)
    {printErrorLine(e.what());
        return ERROR_WHILE_REPORTING;
    }

    // 退出程序
    return handleExit(results.get());
}

看完这个函数我想你应该对 OCLint 剖析流程高深莫测,对于更多实现细节,倡议仔细阅读源码。

6.2 应用默认规定剖析

compile_commands.json 文件所在目录下执行:(oclint-json-compilation-database 是一个帮忙程序,能够简化咱们执行 OCLint 程序。)

oclint-json-compilation-database --verbose -report-type html -o oclint.html -max-priority-1 100000 -max-priority-2 100000 -max-priority-3 100000

留神:
执行胜利会返回 0,除此之外意味着失败。例如,当编译失败时,返回 3;当违规数量大于阀值时,返回 5;当源代码有谬误时,返回 6;

没错,剖析失败了。咱们来看看起因:

oclint: error: Cannot change dictionary into "${本地文件门路,含中文或者特殊字符}", please make sure the directory exists and you have permission to access!

从提醒看可能是权限问题,然而并不是。根因是文件门路中蕴含了中文或特殊字符。想到相似存量问题可能还有很多,写了个脚本扫描一下,具体实现如下:

import os

rootdir=os.getcwd()
if not os.path.isdir(rootdir+'/logout'):
    os.makedirs(rootdir + '/logout')
logPath=os.path.abspath('logout')
file_nonstandard_info=open(logPath+'/non_standard_filename.txt','w')
file_nonstandard_dirname=open(logPath+'/non_standard_dirname.txt','w')

nor_source_file=['png', 'pdf', 'json', 'jpg', 'webp', 'jpeg', 'gif', 'mp3'] #通用资源类型

symbolList=[]   #符号库

def initSymbolList():
    # 规范的符号库
    num="0123456789"
    word="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
    sym="_-+."
    for key in word:
        symbolList.append(key)

    for key in num:
        symbolList.append(key)

    for key in sym:
        symbolList.append(key)

def runCheck():
    for parent,dirnames,filenames in os.walk(this_folder):
        for dirname  in dirnames:
            if (dirname[0] == '.'):
                continue
            dirpath = parent+"/"+dirname
            totalDirList=[]
            for value in dirname:
                totalDirList.append(value)
            if not set(totalDirList).issubset(symbolList):
                file_nonstandard_dirname.write(dirpath+'\n')
        for filename in filenames:
            if filename.find(".") == -1:
                continue
            #过滤资源文件
            if set([filename.split(".")[-1]]).issubset(nor_source_file):
                continue
            totalList=[];
            tempFilename = filename[0:filename.index('.')]
            filepath = parent+"/"+filename
            for value in tempFilename:
                totalList.append(value)
            # 判断文件名是否标准
            if not set(totalList).issubset(symbolList):
                file_nonstandard_info.write(filepath + '\n')
    
this_folder = input("须要检测的文件门路:").replace("\\",'/')
initSymbolList()
runCheck()

针对上述问题,倡议后续做 MR 卡口,避免相似新增问题呈现。

上述问题解决后,retry,新问题又来了。

Traceback (most recent call last): File "/usr/local/bin/oclint-json-compilation-database", line 86, in <module> exit_code = subprocess.call(oclint_arguments) File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.8/lib/python3.8/subprocess.py", line 340, in call with Popen(*popenargs, **kwargs) as p: File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.8/lib/python3.8/subprocess.py", line 858, in __init__ self._execute_child(args, executable, preexec_fn, close_fds, File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.8/lib/python3.8/subprocess.py", line 1704, in _execute_child raise child_exception_type(errno_num, err_msg, err_filename) OSError: [Errno 7] Argument list too long: '/usr/local/bin/oclint

看最初一行,意思是 compile_commands.json 文件太大了。侥幸的是,找到了举荐的解法 –《oclint_argument_list_too_long_solution》。大略的思路是,把 compile_commands.json 文件宰割成 n 个小 json 文件,而后循环解析失去 n 个解析后果,最初把所有的后果合并成一个大的 oclint.xml。
整体流程如下:

当初 OCLint 剖析链路算是初步实现了,默认规定蕴含的内容十分多,通过剖析后果你能够晓得我的项目代码中有哪些问题,后续可依据剖析报告优化代码,进步代码品质、缩小代码缺点,例如 ObjCVerifyMustCallSuperRule 能够检测出哪些地方没有调用 super 函数。

但有时候你并不想 care 所有的规定,比方命名标准,函数名超长等规定你可能想疏忽它们。此时能够通过 -disable-rule 疏忽它们。同时针对特定需要,默认规定可能无奈满足咱们的诉求,此时就须要自定义规定了。

七、如何自定义规定?

这里能够参考 OCLint Documentation,内容十分详尽。但须要留神以下两点:

  1. clone 下来的源码版本须要与 Homebrew 装置的版本对齐,不然会因版本兼容问题无奈应用。
  2. 为了兼容 M1,编译动静库时须要加上 arm64。

编写自定义规定前,咱们能够先相熟下已有的规定,能够帮忙咱们更快更好的把握。

举个例子,在进行 App 启动过耗时剖析时,+load 和 App 启动相干生命周期办法耗时影响占有肯定比重,当初咱们须要检测出我的项目中所有的 +load 和 App 启动相干生命周期办法,以便咱们改良和优化它们,该如何实现呢?

7.1 +load 规定实现

要害代码如下:

class ObjCVerifyLoadCallRule : public AbstractASTVisitorRule<ObjCVerifyLoadCallRule>
{
public:
    ...
    // 规定优先级
    virtual int priority() const override
    {return priority; // 把 priority 替换成你想要的优先级,如 3}

    //override 该办法,这里能够拿到所有的 OC 办法,咱们在此写逻辑,找出 +load 办法
    bool VisitObjCMethodDecl(ObjCMethodDecl *node)
    {string selectorName = node->getSelector().getAsString(); // 拿到办法名
        if (node->isClassMethod() && selectorName == "load") { // 判断是 +load 办法
            string desc = "xxx(替换成形容文案)";
            // 把该节点加到违规汇合中
            addViolation(node, this, desc);
            return false;
        }
        return true;
    }
}

同理,能够实现检测 App 启动相干生命周期办法的 Rule。

规定编写实现后,编译生成动静库,复制到 OCLint 的规定门路下 /usr/local/Caskroom/oclint/22.02/oclint-22.02/lib/oclint/rules

此时咱们能够用 oclint -list-enabled-rules x 命令简略的验证一下规定是否可用,接下来咱们拿自定义的规定剖析试试。

7.2 指定 Rule 剖析

要害代码如下:

def lint(out_file):
    lint_command = '''oclint-json-compilation-database -- \
    --verbose \
    -rule ObjCVerifyLoadCall \
    -rule NEModuleHubLaunch \
    -enable-global-analysis \
    -max-priority-{替换成自定义的 priority}=100000 \
    --report-type pmd \
    -o %s''' % (out_file)
    os.system(lint_command)

咱们指定了 ObjCVerifyLoadCall 和 NEModuleHubLaunch 两个自定义规定,之后依照上述流程就能够轻松搞定了。
但因为云音乐工程编译产物特地大,导致运行一次残缺 OCLint 的工夫约 6 个小时。It’s too long!该如何优化呢?

八、残缺剖析耗时优化

梳理流程咱们发现耗时次要是以下两个中央:

  1. 通过 xcodebuild 与 xcpretty 格式化输入编译产物,50 分钟左右。
  2. 剖析编译产物 compile_commands.json,5 个小时左右。

咱们来思考下剖析编译产物时有没有优化的空间呢?
不难发现,下面解决编译产物大的问题时,通过把大 json,宰割成了 n 个小 json,最初循环解析失去各自的剖析后果。那么咱们是不是能够利用多线程 / 过程的形式,来缩小剖析工夫呢?答案不言而喻。

接下来通过优化脚本,先尝试用多线程计划去事实,后果命令脚本的确多线程同时触发了,然而 OCLint 剖析仍然是 one by one,无奈只能改成多过程的形式。

要害代码如下:

def subProcessLint():
    manager = Manager()
    list = manager.list(lintpy_files) #用于过程间数据同步
    sub_p = []
    for i in range(process_count):
        process_name = 'Process------%02d' %(i+1)
        p = Process(target=lint_subProcess, args=(process_name, list))
        sub_p.append(p)
        p.start()
    for p in sub_p:
        p.join()

def lint_subProcess(name, files):
    while len(files)>0:
        print('process name is', name)
        lint_command = files[0]
        files.remove(lint_command)
        start_time = time.time()
        print('before lint:', lint_command)
        os.system(r'python3 %s' %lint_command)
        print("lint time:",time.time()-start_time)

须要留神的是,OCLint 剖析时默认只辨认 compile_commands.json 文件,所以不能在同一文件门路下进行多过程剖析。这里的做法是把下面的子 json 移到新建的文件目录下,剖析完结后把后果挪回原目录下,最初进行合并操作。剖析时目录构造如下:

要害代码如下:

import os
import sys
import shutil

def lint(out_file):
        lint_command = '''oclint-json-compilation-database -- \
        --verbose \
        -rule ObjCVerifyLoadCall \
        -rule NEModuleHubLaunch \
        -enable-global-analysis \
        -max-priority-{替换成自定义的 priority}=100000 \
        --report-type pmd \
        -o %s''' % (out_file)
        os.system(lint_command)

def rename(file_path, new_name):
    paths = os.path.split(file_path)
    new_path = os.path.join(paths[0], new_name)
    os.rename(file_path, new_path)
    return new_path

dir_path = os.path.dirname(__file__) #当前目录
os.chdir(dir_path)  #扭转当前工作目录
cur_dir = dir_path.rsplit("/", 1)[1] #文件夹名
out_file = cur_dir+'.xml'
json_name = 'compile_commands'+cur_dir[6:]+'.json'
rename(os.path.join(dir_path, json_name), 'compile_commands.json')

lint(out_file)

if os.path.isfile(out_file):
    print (out_file + "is exist")
    #产物移到下层目录
    shutil.move(os.path.join(os.path.dirname(__file__), out_file), os.pardir)
    #删除当前目录
    shutil.rmtree(dir_path)
else:
    print (out_file + "is not exist")

下图是流动监视器的截图,能够看到 5 个 OCLint 剖析过程。在理论利用时,因为 OCLint 高内存与 CPU 耗费,咱们把过程数定为了 3 个。

最终咱们通过上述形式,把运行一次残缺 OCLint 的工夫缩短到 2.5 小时左右,总耗时优化了 58.3%,OCLint 耗时优化了 67.7%。

九、其余

下面拿到的 OCLint 剖析数据可能有些浅显,理论利用时能够按需解析,并可联合线上大盘数据对剖析后果做深加工,最初生成 html 格局的报告更加不便浏览。相似下图:

十、理论案例 – 启动耗时代码检测

此前云音乐技术团队进行了长时间的启动性能优化专项治理,效果显著。在此基础上,如何避免启动性能优化专项治理成绩劣化,成为了下个阶段的重中之重。因而咱们尝试利用代码动态检测能力检测剖析启动耗时相干办法,如 +load 办法、App 启动生命周期办法等,目前已上线稳固运行,获得的成果如下:

  1. 检测到可能的耗时代码 600+,波及业务库 120+。
  2. 联合上述剖析后果,咱们预估实现一期治理后,将优化启动耗时 250ms+。

十一、下一步工作

OCLint 的现状不算太好,且远未实现,好在许多方面都在不断改进,例如准确性、性能和可用性。对于 iOS 开发者来说,Swift 已成为支流,并已在云音乐局部产品和业务中应用,之后咱们会思考接入生态更好的 SwiftLint。

现阶段,云音乐技术团队正在踊跃的搭建和欠缺本人的代码动态检测平台,值得期待。

总结

借助代码动态检测能力,可能及时无效的帮忙咱们发现问题、保障代码品质、避免代码劣化、节俭人力老本。
OCLint 作为一种动态代码剖析工具,致力于进步代码品质、缩小代码缺点,并被宽泛应用。联合它的高扩展性,可定制满足各种需要,例如检测启动耗时代码,并通过多过程技术可大大缩短剖析工夫。业务方也能够轻松参加共建,丰盛规定仓库,同时其余产品线也能够参照此案例疾速搭建各自的代码动态检测服务。

参考资料

  • Clang documentation
  • Clang Static Analyzer
  • Infer
  • OCLint Documentation
  • oclint_argument_list_too_long_solution
  • Python 教程
  • SwiftLint

本文公布自网易云音乐技术团队,文章未经受权禁止任何模式的转载。咱们长年招收各类技术岗位,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!

正文完
 0