iOS App 如何經過注入動態庫的方式實現極速編譯調試

APP編譯的流程

解釋器 & 編譯器

你是否是常常會好奇本身參與的這麼些項目,爲何有的編譯起來很快,有的卻很慢;編譯完成後,有的啓動得很快,有的卻很慢。其實,在理解了編譯和啓動時連接器所作的事兒以後,你就能夠從根兒上找到這些問題的答案了。git

  • 解釋器:運行時纔去解析代碼
    • 解釋器會在運行時解釋執行代碼,獲取一段代碼後就會將其翻譯成目標代碼(就是字節碼(Bytecode)),而後一句一句地執行目標代碼。解釋器,是在運行時纔去解析代碼,這樣就比在運行以前經過編譯器生成一份完整的機器碼再去執行的效率要低。
    • 解釋器能夠在運行時去執行代碼,說明它具備動態性,程序運行後可以隨時經過增長和更新代碼來改變程序的邏輯。
  • 編譯器:編譯時,編譯器把代碼編譯成機器碼,而後直接在 CPU 上執行機器碼的
    • 連接器:最主要的做用,就是將符號綁定到地址上

那麼,使用編譯器和解釋器執行代碼的特色,咱們就能夠歸納以下github

  • 採用編譯器生成機器碼執行的好處是效率高,缺點是調試周期長
  • 解釋器執行的好處是編寫調試方便,缺點是執行效率低。

iOS 開發使用的究竟是什麼編譯器

如今蘋果公司使用的編譯器是 LLVM,相比於 Xcode 5 版本前使用的 GCC,編譯速度提升了 3 倍shell

LLVM 是編譯器工具鏈技術的一個集合。而其中的 lldb 項目,就是內置連接器。編程

編譯器會對每一個文件進行編譯,生成 Mach-O(可執行文件);連接器會將項目中的多個 Mach-O 文件合併成一個。swift

我先簡單爲你總結下編譯的幾個主要過程:xcode

  • 一、你寫好代碼後,LLVM 會預處理你的代碼,好比把宏嵌入到對應的位置
  • 二、預處理完後,LLVM 會對代碼進行詞法分析和語法分析,生成 AST 。AST 是抽象語法樹,結構上比代碼更精簡,遍歷起來更快,因此使用 AST 可以更快速地進行靜態檢查,同時還能更快地生成 IR(中間表示)
  • 三、最後 AST 會生成 IR,IR 是一種更接近機器碼的語言,區別在於和平臺無關,經過 IR 能夠生成多份適合不一樣平臺的機器碼。對於 iOS 系統,IR 生成的可執行文件就是 Mach-O。

編譯時連接器作了什麼?

Mach-O 文件裏面的內容,主要就是代碼和數據緩存

  • 代碼是函數的定義
  • 數據是全局變量的定義,包括全局變量的初始值

不論是代碼仍是數據,它們的實例都須要由符號將其關聯起來bash

由於 Mach-O 文件裏的那些代碼,好比 if、for、while 生成的機器指令序列,要操做的數據會存儲在某個地方,變量符號就須要綁定到數據的存儲地址。你寫的代碼還會引用其餘的代碼,引用的函數符號也須要綁定到該函數的地址上網絡

而連接器的做用,就是完成變量、函數符號和其地址綁定這樣的任務。而這裏咱們所說的符號,就能夠理解爲變量名和函數名app

那爲何要讓連接器作符號和地址綁定這樣一件事兒呢?不綁定的話,又會有什麼問題?

若是地址和符號不作綁定的話,要讓機器知道你在操做什麼內存地址,你就須要在寫代碼時給每一個指令設好內存地址。寫這樣的代碼的過程,就像你直接在和不一樣平臺的機器溝通,連編譯生成 AST 和 IR 的步驟都省掉了,甚至優化平臺相關的代碼都須要你本身編寫。

這件事兒看起來挺酷,但可讀性和可維護性都會不好,好比修改代碼後對地址的維護就會讓你崩潰。而這種「崩潰」的罪魁禍首就是代碼和內存地址綁定得太早

另外,綁定得太早除了可讀性和可維護性差以外,還會有更多的重複工做。由於,你須要針對不一樣的平臺寫多份代碼,而這些代碼本能夠經過高級語言一次編譯成多份。既然這樣,那咱們應該怎麼辦呢?

咱們首先想到的就是,用匯編語言來讓這種綁定滯後。隨着編程語言的進化,咱們很快就發現,採用任何一種高級編程語言,均可以解決代碼和內存綁定過早產生的問題,同時還能掃掉使用匯編寫程序的煩惱。

連接器爲何還要把項目中的多個 Mach-O 文件合併成一個

你確定不但願一個項目是在一個文件裏從頭寫到尾的吧。項目中文件之間的變量和接口函數都是相互依賴的,因此這時咱們就須要經過連接器將項目中生成的多個 Mach-O 文件的符號和地址綁定起來。

沒有這個綁定過程的話,單個文件生成的 Mach-O 文件是沒法正常運行起來的。由於,若是運行時碰到調用在其餘文件中實現的函數的狀況時,就會找不到這個調用函數的地址,從而沒法繼續執行。

連接器在連接多個目標文件的過程當中,會建立一個符號表,用於記錄全部已定義的和全部未定義的符號。連接時若是出現相同符號的狀況,就會出現「ld: dumplicate symbols」的錯誤信息;若是在其餘目標文件裏沒有找到符號,就會提示「Undefined symbols」的錯誤信息。

連接器對代碼主要作了哪幾件事兒。

  • 一、去項目文件裏查找目標代碼文件裏沒有定義的變量。
  • 二、掃描項目中的不一樣文件,將全部符號定義和引用地址收集起來,並放到全局符號表中。
  • 三、計算合併後長度及位置,生成同類型的段進行合併,創建綁定
  • 四、對項目中不一樣文件裏的變量進行地址重定位

你在項目裏爲某項需求寫了一些功能函數,但隨着業務的發展,一些功能被下掉了或者被其餘負責的同事在另外一個文件裏用其餘函數更新了功能。那麼這時,你之前寫的那些函數就沒有用武之地了。日長月久,無用的函數愈來愈多,生成的 Mach-O 文件也就愈來愈大。

這時,連接器在整理函數的符號調用關係時,就能夠幫你理清有哪些函數是沒被調用的,並自動去除掉。那這是怎麼實現的呢?

連接器在整理函數的調用關係時,會以 main 函數爲源頭,跟隨每一個引用,並將其標記爲 live。跟隨完成後,那些未被標記 live 的函數,就是無用函數。而後,連接器能夠經過打開 Dead code stripping 開關,來開啓自動去除無用代碼的功能。而且,這個開關是默認開啓的。

動態庫連接

在真實的 iOS 開發中,你會發現不少功能都是現成可用的,不光你可以用,其餘 App 也在用,好比 GUI 框架、I/O、網絡等。連接這些共享庫到你的 Mach-O 文件,也是經過連接器來完成的

連接的共用庫分爲靜態庫和動態庫

  • 一、靜態庫是編譯時連接的庫
    • 須要連接進你的 Mach-O 文件裏,若是須要更新就要從新編譯一次,沒法動態加載和更新
  • 二、動態庫是運行時連接的庫,使用 dyld 就能夠實現動態加載。

Mach-O 文件是編譯後的產物,而動態庫在運行時纔會被連接,並沒參與 Mach-O 文件的編譯和連接。

因此 Mach-O 文件中並無包含動態庫裏的符號定義。也就是說,這些符號會顯示爲「未定義」,但它們的名字和對應的庫的路徑會被記錄下來。運行時經過 dlopen 和 dlsym 導入動態庫時,先根據記錄的庫路徑找到對應的庫,再經過記錄的名字符號找到綁定的地址。

dlopen 會把共享庫載入運行進程的地址空間,載入的共享庫也會有未定義的符號,這樣會觸發更多的共享庫被載入。dlopen 也能夠選擇是馬上解析全部引用仍是滯後去作。dlopen 打開動態庫後返回的是引用的指針,dlsym 的做用就是經過 dlopen 返回的動態庫指針和函數符號,獲得函數的地址而後使用。

使用 dyld 加載動態庫,有兩種方式

  • 一、程序啓動加載時綁定
  • 二、符號第一次被用到時綁定

爲了減小啓動時間,大部分動態庫使用的都是符號第一次被用到時再綁定的方式。

加載過程開始會修正地址偏移,iOS 會用 ASLR 來作地址偏移避免攻擊,肯定 Non-Lazy Pointer 地址進行符號地址綁定,加載全部類,最後執行 load 方法和 Clang Attribute 的 constructor 修飾函數。每一個函數、全局變量和類都是經過符號的形式定義和使用的,當把目標文件連接成一個 Mach-O 文件時,連接器在目標文件和動態庫之間對符號作解析處理

這裏系統上的動態連接器會使用共享緩存,共享緩存在 /var/db/dyld/。當加載 Mach-O 文件時,動態連接器會先檢查是否有共享緩存。每一個進程都會在本身的地址空間映射這些共享緩存,這樣作能夠起到優化 App 啓動速度的做用

簡單來講, dyld 作了這麼幾件事兒:

  • 一、先執行 Mach-O 文件,根據 Mach-O 文件裏 undefined 的符號加載對應的動態庫,系統會設置一個共享緩存來解決加載的遞歸依賴問題
  • 二、加載後,將 undefined 的符號綁定到動態庫裏對應的地址上;
  • 三、最後再處理 +load 方法,main 函數返回後運行 static terminator。

Injection使用

John Holdsworth 開發了一個叫做 Injection 的工具能夠動態地將 Swift 或 Objective-C 的代碼在已運行的程序中執行,以加快調試速度,同時保證程序不用重啓

一、使用

Injection 是咱們須要用到個一個工具,不要由於要用一個工具而厭煩這個方案,它很簡單。 它是免費的,app store 搜索:InjectionIII,Icon是 一個針筒。 也是開源的,

二、配置路徑

打開InjectionIII工具,選擇Open Project,選擇你的代碼所在的路徑,而後點擊Select Project Directory保存

三、導入配置文件

1.設置AppDelegate.m 打開你的源碼,在AppDelegate.m的didFinishLaunchingWithOptions方法添加一行代碼

#if DEBUG
    // iOS
    [[NSBundle bundleWithPath:@"/Applications/InjectionIII.app/Contents/Resources/iOSInjection.bundle"] load];
 #endif
複製代碼

2.設置ViewController 在須要修改界面的ViewController添加方法- (void)injected,或者給ViewController類擴展添加方法- (void)injected。 全部修改控件的代碼都寫在這裏面

- (void)injected
{
    [self viewDidLoad];
}

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    
    UIView *red = [[UIView alloc]initWithFrame:CGRectMake(100, 100, 100, 100)];
    red.backgroundColor = [UIColor redColor];
    [self.view addSubview:red];
    
}
複製代碼

四、啓動項目,修改驗證

在Xcode Command+R運行項目 ,看到Injection connected 提示即表示配置成功。

💉 Injection connected 👍
💉 Have you remembered to add "-Xlinker -interposable" to your project's "Other Linker Flags"? 💉 Watching /Users/yunna/Desktop/a/** 複製代碼

在須要修改的頁面,修改控件UI,而後Command+S保存一下代碼,馬上就在模擬器上顯示修改的信息了。

五、每一個VC要使用的話,還須要去寫injected,有點煩人,可是咱們有方案

用runtime 給每一個VC加個方法class_addMethod 依託InjectionIII的iOS熱部署配置文件,無侵害,導入即用。

@implementation InjectionIIIHelper

#if DEBUG
/**
InjectionIII 熱部署會調用的一個方法,
runtime給VC綁定上以後,每次部署完就從新viewDidLoad
*/
void injected (id self, SEL _cmd) {
  //從新加載view
  [self loadView];
  [self viewDidLoad];
  [self viewWillLayoutSubviews];
  [self viewWillAppear:NO];
}

+ (void)load
{
  //註冊項目啓動監聽
  __block id observer =
  [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidFinishLaunchingNotification object:nil queue:nil usingBlock:^(NSNotification * _Nonnull note) {
  //更改bundlePath
  [[NSBundle bundleWithPath:@"/Applications/InjectionIII.app/Contents/Resources/iOSInjection.bundle"] load];
  //[[NSBundle bundleWithPath:@"/Applications/InjectionIII.app/Contents/Resources/iOSInjection.bundle"] load];

  [[NSNotificationCenter defaultCenter] removeObserver:observer];
  }];

  //給UIViewController 註冊injected 方法
  class_addMethod([UIViewController class], NSSelectorFromString(@"injected"), (IMP)injected, "v@:");

}
#endif
@end

複製代碼

六、Injection 是怎麼作到的呢

Injection 會監聽源代碼文件的變化,若是文件被改動了,Injection Server 就會執行 rebuildClass 從新進行編譯、打包成動態庫,也就是 .dylib 文件。編譯、打包成動態庫後使用 writeSting 方法經過 Socket 通知運行的 App

- (BOOL)writeString:(NSString *)string {
    const char *utf8 = string.UTF8String;
    uint32_t length = (uint32_t)strlen(utf8);
    if (write(clientSocket, &length, sizeof length) != sizeof length ||
        write(clientSocket, utf8, length) != length)
        return FALSE;
    return TRUE;
}
複製代碼

Server 會在後臺發送和監聽 Socket 消息,實現邏輯在 InjectionServer.mm 的 runInBackground 方法裏。Client 也會開啓一個後臺去發送和監聽 Socket 消息,實現邏輯在 InjectionClient.mm裏的 runInBackground 方法裏。

Client 接收到消息後會調用 inject(tmpfile: String) 方法,運行時進行類的動態替換。inject(tmpfile: String) 方法的具體實現代碼,你能夠點擊這個連接查看。

inject(tmpfile: String) 方法的代碼大部分都是作新類動態替換舊類。inject(tmpfile: String) 的入參 tmpfile 是動態庫的文件路徑,那麼這個動態庫是如何加載到可執行文件裏的呢?具體的實如今 inject(tmpfile: String) 方法開始裏,以下:

let newClasses = try SwiftEval.instance.loadAndInject(tmpfile: tmpfile)
複製代碼

你先看下 SwiftEval.instance.loadAndInject(tmpfile: tmpfile) 這個方法的代碼實現:

@objc func loadAndInject(tmpfile: String, oldClass: AnyClass? = nil) throws -> [AnyClass] {

    print("???? Loading .dylib - Ignore any duplicate class warning...")
    // load patched .dylib into process with new version of class
    guard let dl = dlopen("\(tmpfile).dylib", RTLD_NOW) else {
        throw evalError("dlopen() error: \(String(cString: dlerror()))")
    }
    print("???? Loaded .dylib - Ignore any duplicate class warning...")

    if oldClass != nil {
        // find patched version of class using symbol for existing

        var info = Dl_info()
        guard dladdr(unsafeBitCast(oldClass, to: UnsafeRawPointer.self), &info) != 0 else {
            throw evalError("Could not locate class symbol")
        }

        debug(String(cString: info.dli_sname))
        guard let newSymbol = dlsym(dl, info.dli_sname) else {
            throw evalError("Could not locate newly loaded class symbol")
        }

        return [unsafeBitCast(newSymbol, to: AnyClass.self)]
    }
    else {
        // grep out symbols for classes being injected from object file

        try injectGenerics(tmpfile: tmpfile, handle: dl)

        guard shell(command: """ \(xcodeDev)/Toolchains/XcodeDefault.xctoolchain/usr/bin/nm \(tmpfile).o | grep -E ' S _OBJC_CLASS_\\$_| _(_T0|\\$S).*CN$' | awk '{print $3}' >\(tmpfile).classes """) else {
            throw evalError("Could not list class symbols")
        }
        guard var symbols = (try? String(contentsOfFile: "\(tmpfile).classes"))?.components(separatedBy: "\n") else {
            throw evalError("Could not load class symbol list")
        }
        symbols.removeLast()

        return Set(symbols.flatMap { dlsym(dl, String($0.dropFirst())) }).map { unsafeBitCast($0, to: AnyClass.self) }
複製代碼

在這段代碼中,你是否是看到你所熟悉的動態庫加載函數 dlopen 了呢?

guard let dl = dlopen("\(tmpfile).dylib", RTLD_NOW) else {
    throw evalError("dlopen() error: \(String(cString: dlerror()))")
}
複製代碼

如上代碼所示,dlopen 會把 tmpfile 動態庫文件載入運行的 App 裏,返回指針 dl。接下來,dlsym 會獲得 tmpfile 動態庫的符號地址,而後就能夠處理類的替換工做了。dlsym 調用對應代碼以下:

guard let newSymbol = dlsym(dl, info.dli_sname) else {
    throw evalError("Could not locate newly loaded class symbol")
}
複製代碼

當類的方法都被替換後,咱們就能夠開始從新繪製界面了。整個過程無需從新編譯和重啓 App,至此使用動態庫方式極速調試的目的就達成了。

我把 Injection 的工做原理用一張圖表示了出來,以下所示:

文章轉載自:

相關文章
相關標籤/搜索