app启动页数秒加载 代码_iOS 底层探索 - 应用加载
一、前導知識
以下參考自 WWDC 2016 Optimizing App Startup Time :
1.1 Mach-O
Mach-O is a bunch of file types for different run time executables.Mach-O 是 iOS 系統不同運行時期可執行的文件的文件類型統稱。維基百科上關于 Mach-O 的描述:
Mach-O 是 Mach object 文件格式的縮寫,它是一種用于記錄可執行文件、對象代碼、共享庫、動態加載代碼和內存轉儲的文件格式。作為 a.out 格式的替代品,Mach-O 提供了更好的擴展性,并提升了符號表中信息的訪問速度。大多數基于 Mach 內核的操作系統都使用 Mach-O。NeXTSTEP、OS X 和 iOS 是使用這種格式作為本地可執行文件、庫和對象代碼的例子。
Mach-O 有三種文件類型: Executable、Dylib、Bundle
- Executable 類型
我們一般可以在 Xcode 項目中的 Products 文件夾中找到它:
如上圖箭頭所示,App加載流程 就是我們 App 的二進制主文件。
- Dylib 類型
對于接觸 iOS 開發比較早的同學,可能知道我們在 Xcode 7 之前添加一些比如 sqlite 的庫的時候,其后綴名為 dylib,而 Xcode 7 之后后綴名都改成了 tbd。
這里引用 StackoverFlow 上的一篇回答。
So it appears that the .dylib file is the actual library of binary code that your project is using and is located in the /usr/lib/ directory on the user's device. The .tbd file, on the other hand, is just a text file that is included in your project and serves as a link to the required .dylib binary. Since this text file is much smaller than the binary library, it makes the SDK's download size smaller.看起來 .dylib 文件是項目中真正使用到的二進制庫文件,它位于用戶設備上的 /usr/lib 目錄下。而 .tbd 文件,只是位于你項目中的一個文本文件,它扮演的是鏈接到真正的 .dylib 二進制文件的角色。因為文本文件的大小遠遠小于二進制文件的大小,所以讓 Xcode 的SDK` 的下載大小更小。
這里再插一句,那么有動態庫,肯定就有靜態庫,它們的區別是什么呢?
我們先梳理一下整個的編譯過程。
當然,這個過程中間其實還設計到編譯器前端的 詞法分析、語法分析、語義分析、優化 等流程,我們在后面探索 LLVM 和 Clang 的時候會詳細介紹。
回到剛才的話題,靜態庫和動態庫的區別:
Static frameworks are linked at compile time. Dynamic frameworks are linked at runtime.靜態庫和動態庫都是編譯好的二進制文件,只是用法不同。那為什么要分動態和靜態庫呢?
通過上面兩幅圖我們可以知道:
- 靜態庫表現為:在鏈接階段會將匯編生成的目標文件與引用的庫一起鏈接打包進可執行文件中。
- 動態庫表現為:程序編譯并不會鏈接到目標代碼中,在程序可執行文件里面會保留對動態庫的引用。其中,動態庫分為動態鏈接庫和動態加載庫。
- 動態鏈接庫:在沒有被加載到內存的前提下,當可執行文件被加載,動態庫也隨著被加載到內存中。在 Linked Framework and Libraries 設置的一些 share libraries。【隨著程序啟動而啟動】
- 動態加載庫:當需要的時候再使用 dlopen 等通過代碼或者命令的方式來加載。【在程序啟動之后】
- Bundle 類型
現階段 Bundle 是一種特殊類型的 dylib,你是無法對其進行鏈接的。你所能做的是在 Runtime 運行時去通過 dlopen 來加載它,它可以在 macOS 上用于插件。
- Image 和 Framework
鏡像文件包含了上述的三種文件類型
a framework is a dylib with a special directory structure around it to holds files needed by that dylib.
有很多東西都叫做 Framework,但在本文中,Framework 指的是一個 dylib,它周圍有一個特殊的目錄結構來保存該 dylib 所需的文件。
1.1.1 Mach-O 結構分析
1.1.1.1 segment 段
Mach-O 鏡像文件是由 segments 段組成的。
- 段的名稱為大寫格式
所有的段都是 page size 的倍數。
- arm64 上段大小為 16 字節
- 其它架構為 4 字節
這里再普及一下虛擬內存和內存頁的知識:
具有 VM 機制的操作系統,會對每個運行的進程創建一個邏輯地址空間 logical address space 或者叫虛擬地址空間 virtual address space;該空間的大小由操作系統位數決定:32 位的操作系統,其邏輯地址空間的大小為 4GB,64位的操作系統為 18 exabyes(其計算方式是 2^32 || 2^64)。虛擬地址空間(或者邏輯地址空間)會被分為相同大小的塊,這些塊被稱為內存頁(page)。計算機處理器和它的內存管理單元(MMU - memory management uinit)維護著一張將程序的邏輯地址空間映射到物理地址上的分頁表 page table。在 masOS 和早版本的 iOS 中,分頁的大小為 4kB。在之后的基于 A7 和 A8 的系統中,虛擬內存(64 位的地址空間)地址空間的分頁大小變為了 16KB,而物理RAM上的內存分頁大小仍然維持在 4KB;基于A9及之后的系統,虛擬內存和物理內存的分頁都是16KB。
1.1.1.2 section
在 segment 段內部還有許多的 section 區。section 名稱為小寫格式。
But sections are really just a subrange of a segment, they don't have any of the constraints of being page size, but they are non-overlapping.但是 sections 節實際上只是一個 segment 段的子范圍,它們沒有頁面大小的任何限制,但是它們是不重疊的。
通過 MachOView 工具查看 app 的二進制可執行文件可以查看到:
1.1.1.3 常見的 segments
- __TEXT:代碼段,包括頭文件、代碼和常量。只讀不可修改
- __DATA:數據段,包括全局變量, 靜態變量等。可讀可寫。
- __LINKEDIT:如何加載程序, 包含了方法和變量的元數據(位置,偏移量),以及代碼簽名等信息。只讀不可修改。
1.1.2 Mach-O Universal Files
Mach-O 通用文件,將多種架構的 Mach-O 文件合并而成。它通過 header 來記錄不同架構在文件中的偏移量,segement 占多個分頁,header占一頁的空間。可能有人會覺得 header 單獨占一頁會浪費空間,但這有利于虛擬內存的實現。
1.2 虛擬內存
虛擬內存是一層間接尋址。
虛擬內存解決的是管理所有進程使用物理 RAM 的問題。通過添加間接層來讓每個進程使用邏輯地址空間,它可以映射到 RAM 上的某個物理頁上。這種映射不是一對一的,邏輯地址可能映射不到 RAM 上,也可能有多個邏輯地址映射到同一個物理 RAM 上。
- 針對第一種情況,當進程要存儲邏輯地址內容時會觸發 page fault。
- 而第二種情況就是多進程共享內存。
- 對于文件可以不用一次性讀入整個文件,可以使用分頁映射 mmap() 的方式讀取。也就是把文件某個片段映射到進程邏輯內存的某個頁上。當某個想要讀取的頁沒有在內存中,就會觸發 page fault,內核只會讀入那一頁,實現文件的懶加載。也就是說 Mach-O 文件中的 __TEXT 段可以映射到多個進程,并可以懶加載,且進程之間共享內存。
- __DATA 段是可讀寫的。這里使用到了 Copy-On-Write 技術,簡稱 COW。也就是多個進程共享一頁內存空間時,一旦有進程要做寫操作,它會先將這頁內存內容復制一份出來,然后重新映射邏輯地址到新的 RAM 頁上。也就是這個進程自己擁有了那頁內存的拷貝。這就涉及到了 clean/dirty page 的概念。dirty page 含有進程自己的信息,而 clean page 可以被內核重新生成(重新讀磁盤)。所以 dirty page 的代價大于 clean page。
1.3 多進程加載 Mach-O 鏡像
- 所以在多個進程加載 Mach-O 鏡像時 __TEXT 和 __LINKEDIT 因為只讀,都是可以共享內存的,讀取速度就會很快。
- 而 __DATA 因為可讀寫,就有可能會產生 dirty page,如果檢測到有 clean page 就可以直接使用,反之就需要重新讀取 DATA page。一旦產生了 dirty page,當 dyld 執行結束后,__LINKEDIT 需要通知內核當前頁面不再需要了,當別人需要的使用時候就可以重新 clean 這些頁面。
1.4 ASLR
ASLR (Address Space Layout Randomization) 地址空間布局隨機化,鏡像會在隨機的地址上加載。
1.5 Code Signing
可能我們認為 Xcode 會把整個文件都做加密 hash 并用做數字簽名。其實為了在運行時驗證 Mach-O 文件的簽名,并不是每次重復讀入整個文件,而是把每頁內容都生成一個單獨的加密散列值,并存儲在 __LINKEDIT 中。這使得文件每頁的內容都能及時被校驗確并保不被篡改。
1.6 exec()
Exec is a system call. When you trap into the kernel, you basically say I want to replace this process with this new program.exec() 是一個系統調用。系統內核把應用映射到新的地址空間,且每次起始位置都是隨機的(因為使用 ASLR)。并將起始位置到 0x000000 這段范圍的進程權限都標記為不可讀寫不可執行。如果是 32 位進程,這個范圍至少是 4KB;對于 64 位進程則至少是 4GB 。NULL 指針引用和指針截斷誤差都是會被它捕獲。這個范圍也叫做 PAGEZERO。1.7 dyld
Unix 的前二十年很安逸,因為那時還沒有發明動態鏈接庫。有了動態鏈接庫后,一個用于加載鏈接庫的幫助程序被創建。在蘋果的平臺里是 dyld,其他 Unix 系統也有 ld.so。 當內核完成映射進程的工作后會將名字為 dyld 的 Mach-O 文件映射到進程中的隨機地址,它將 PC 寄存器設為 dyld 的地址并運行。dyld 在應用進程中運行的工作是加載應用依賴的所有動態鏈接庫,準備好運行所需的一切,它擁有的權限跟應用一樣。1.8 dyld 流程
- Load dylibs
- Fix-ups
現代 code-gen 被叫做動態 PIC(Position Independent Code),意味著代碼可以被加載到間接的地址上。當調用發生時,code-gen 實際上會在 __DATA 段中創建一個指向被調用者的指針,然后加載指針并跳轉過去。所以 dyld 做的事情就是修正(fix-up)指針和數據。Fix-up 有兩種類型,rebasing和 binding。
- Rebasing 和 Binding
Binding:將指針指向鏡像外部的內容
dyld 的時間線由上圖可知為:
Load dylibs -> Rebase -> Bind -> ObjC -> Initializers
1.9 dyld2 && dyld3
在 iOS 13 之前,所有的第三方 App 都是通過 dyld 2 來啟動 App 的,主要過程如下:
- 解析 Mach-O 的 Header 和 Load Commands,找到其依賴的庫,并遞歸找到所有依賴的庫
- 加載 Mach-O 文件
- 進行符號查找
- 綁定和變基
- 運行初始化程序
dyld3 被分為了三個組件:
- 一個進程外的 MachO 解析器
- 預先處理了所有可能影響啟動速度的 search path、@rpaths 和環境變量
- 然后分析 Mach-O 的 Header 和依賴,并完成了所有符號查找的工作
- 最后將這些結果創建成了一個啟動閉包
- 這是一個普通的 daemon 進程,可以使用通常的測試架構
- 一個進程內的引擎,用來運行啟動閉包
- 這部分在進程中處理
- 驗證啟動閉包的安全性,然后映射到 dylib 之中,再跳轉到 main 函數
- 不需要解析 Mach-O 的 Header 和依賴,也不需要符號查找。
- 一個啟動閉包緩存服務
- 系統 App 的啟動閉包被構建在一個 Shared Cache 中, 我們甚至不需要打開一個單獨的文件
- 對于第三方的 App,我們會在 App 安裝或者升級的時候構建這個啟動閉包。
- 在 iOS、tvOS、watchOS中,這這一切都是 App 啟動之前完成的。在 macOS 上,由于有 Side Load App,進程內引擎會在首次啟動的時候啟動一個 daemon 進程,之后就可以使用啟動閉包啟動了。
dyld 3 把很多耗時的查找、計算和 I/O 的事前都預先處理好了,這使得啟動速度有了很大的提升。
好了,先導知識就總結到這里,接下來讓我們調整呼吸進入下一章~
二、App 加載分析
我們在探索 iOS 底層的時候,對于對象、類、方法有了一定的認知哦,接下來我們就一起來探索一下應用是怎么加載的。
我們直接新建一個 Single View App 的項目,然后在 main.m 中打一個斷點:
然后我們可以看到在 main 方法執行前有一步 start,而這一流程是由 libdyld.dylib 這個動態庫來執行的。
這個現象說明了什么呢?說明我們的 app 在 main 函數執行之前其實還通過 dyld 做了很多事情。那為了搞清楚具體的流程,我們不妨從 Apple OpenSource 上下載 dyld 的源碼來進行探索。
我們選擇最新的 655.1.1 版本:
三、dyld 源碼分析
面對 dyld 的源碼,我們不可能一行一行的去分析。我們不妨在剛才創建的項目中斷點一下 load 方法,看下調用堆棧:
這一次我們發現,load 方法的調用要早于 main 函數的調用,其次,我們得到了一個非常有價值的線索: _dyld_start。
3.1 _dyld_start
我們直接在 dyld 655.1.1 中全局搜索這個 _dyld_start,我們可以來到 dyldStartup.s 這個匯編文件,然后我們聚焦于 arm64 架構下的匯編代碼:
對于這里的匯編代碼,我們肯定也沒必要逐行分析,我們直接定位到 bl 語句后面(bl 在匯編層面是跳轉的意思):
bl __ZN13dyldbootstrap5startEPK12macho_headeriPPKclS2_Pm我們可以看到這里有一行注釋:
// call dyldbootstrap::start(app_mh, argc, argv, slide, dyld_mh, &startGlue)這行注釋的意思是調用位于 dyldbootstrap 命名空間下的 start 方法,我們繼續搜索一下這個 start 方法,結果位于 dyldInitialization.cpp 文件(從文件名我們可以看出該文件主要是用來初始化 dyld),這里查找 start 的時候可能會有很多結果,我們其實可以先搜索命名空間,再搜索 start 方法。
3.2 dyldbootstrap::start
start 方法源碼如下:
// // This is code to bootstrap dyld. This work in normally done for a program by dyld and crt. // In dyld we have to do this manually. // uintptr_t start(const struct macho_header* appsMachHeader, int argc, const char* argv[], intptr_t slide, const struct macho_header* dyldsMachHeader,uintptr_t* startGlue) {// if kernel had to slide dyld, we need to fix up load sensitive locations// we have to do this before using any global variablesslide = slideOfMainExecutable(dyldsMachHeader);bool shouldRebase = slide != 0; #if __has_feature(ptrauth_calls)shouldRebase = true; #endifif ( shouldRebase ) {rebaseDyld(dyldsMachHeader, slide);}// allow dyld to use mach messagingmach_init();// kernel sets up env pointer to be just past end of agv arrayconst char** envp = &argv[argc+1];// kernel sets up apple pointer to be just past end of envp arrayconst char** apple = envp;while(*apple != NULL) { ++apple; }++apple;// set up random value for stack canary__guard_setup(apple);#if DYLD_INITIALIZER_SUPPORT// run all C++ initializers inside dyldrunDyldInitializers(dyldsMachHeader, slide, argc, argv, envp, apple); #endif// now that we are done bootstrapping dyld, call dyld's mainuintptr_t appsSlide = slideOfMainExecutable(appsMachHeader);return dyld::_main(appsMachHeader, appsSlide, argc, argv, envp, apple, startGlue); }我們剛才探索到了 start 方法,具體流程如下:
- 根據 dyld 的 Mach-O 文件的 header 判斷是否需要對 dyld 這個 Mach-O 進行 rebase 操作
- 初始化 mach,使得 dyld 可以進行 mach 通訊。
- 內核將 env 指針設置為剛好超出 agv 數組的末尾;內核將 apple 指針設置為剛好超出 envp 數組的末尾
- 棧溢出保護
- 讀取 app 主二進制文件 Mach-O 的 header 來得到偏移量 appSlide,然后調用 dyld 命名空間下的 _main 方法。
3.3 dyldbootstrap::_main
我們通過搜索來到 dyld.cpp 文件下的 _main 方法:
_main方法 官方的注釋如下:
dyld 的入口。內核加載了 dyld 然后跳轉到 __dyld_start 來設置一些寄存器的值然后調用到了這個方法。返回 __dyld_start 所跳轉到的目標程序的 main 函數地址。
我們乍一看,這個方法有四五百行,所以我們不能老老實實的一行一行來看,這樣太累了。我們應該著重于有注釋的地方。
- 我們首先可以看到這里是從環境變量中獲取主要可執行文件的 cdHash 值。這個哈希值 mainExecutableCDHash 在后面用來校驗 dyld3 的啟動閉包。
- 上圖代碼作用是追蹤 dyld 的加載。然后判斷當前是否為模擬器環境,如果不是模擬器,則追蹤主二進制可執行文件的加載。
- 顯示宏定義判斷是否為 macOS 執行環境,如果是則判斷 DYLD_ROOT_PATH 環境變量是否存在,如果存在,然后判斷模擬器是否有自己的 dyld,如果有就使用,如果沒有,則返回錯誤信息。
- 打印日志:dyld 啟動開始
- 根據傳入 dyldbootstrap::_main 方法的參數來設置上下文
- 拾取指向 exec 路徑的指針
- 從 dyl d移除臨時 apple [0] 過渡代碼
- 判斷 exec 路徑是否為絕對路徑,如果為相對路徑,使用 cwd 轉化為絕對路徑
- 為了后續的日志打印從 exec 路徑中取出進程的名稱 (strrchr 函數是獲取第二個參數出現的最后的一個位置,然后返回從這個位置開始到結束的內容)
- 根據 App 主二進制可執行文件 Mach-O 的 Header 的內容配置進程的一些限制條件
- 判斷是否為 macOS 執行環境,如果是的話,再判斷上下文的一些配置屬性是否被設置了,如果沒有被設置,則再次進行一次 setContext 上下文配置操作。
- 根據傳入的參數 envp 檢查環境變量
- 默認未初始化的后備路徑
- 判斷是否為 macOS 執行環境,如果是的話,再判斷當前 app 的 Mach-O 可執行文件是否為 iOSMac 類型且不為 macOS 類型的話,則重置上下文的根路徑,然后再判斷 DYLD_FALLBACK_LIBRARY_PATH 和 DYLD_FALLBACK_FRAMEWORK_PATH 這兩個環境變量是否都是默認后備路徑,如果是的話賦值為受限的后備路徑。
- 根據環境變量 DYLD_PRINT_OPTS 和 DYLD_PRINT_ENV 來判斷是否需要打印
- 通過當前 app 的 Mach-O 可執行文件的 header 和 ASLR 之后的偏移量來獲取架構信息。在這里會判斷如果是 GC 的程序則會禁用掉共享緩存。
- 判斷共享緩存是否開啟,如果開啟了就將共享緩存映射到當前進程的邏輯內存空間內
- 檢查共享緩存這里會先判斷 app 的 Mach-O 二進制可執行文件是否有段覆蓋了共享緩存區域,如果覆蓋了則禁用共享緩存。但是這里的前提是 macOS,在 iOS 中,共享緩存是必需的。
- 通過共享緩存中的頭的版本信息來判斷是走 dyld 2 還是 dyld 3 的流程
3.4 dyld3 的處理
- 由于 dyld3 會創建一個啟動閉包,我們需要來讀取它,這里會現在緩存中查找是否有啟動閉包的存在,前面我們已經說過了,系統級的 app 的啟動閉包是存在于共享緩存中,而我們自己開發的 app的啟動閉包是在 app 安裝或者升級的時候構建的,所以這里檢查 dyld 中的緩存是有意義的。
- 宏定義判斷代碼執行條件為真機。
- 如果 dyld 緩存中沒有找到啟動閉包或者找到了啟動閉包但是驗證失敗(我們最開始提到的 cdHash在這里出現了)
- 從啟動閉包緩存中查找
- 如果還是沒有找到,那就創建一個新的啟動閉包
- 從啟動閉包緩存中查找
- 打印日志信息:dyld3 啟動開始
- 嘗試通過啟動閉包進行啟動
- 如果啟動失敗,則創建一個新的啟動閉包嘗試再次啟動
- 如果啟動成功,由于 start() 是以函數指針的方式調用 _main 方法的返回的指針,需要進行簽名。
至此,dyld3 的流程就處理完畢,我們再接著往下分析 dyld2 的流程。
3.5 dyld2 的處理
- 這里會添加 dyld 的鏡像文件到 UUID 列表中,主要的目的是啟用堆棧的符號化。
reloadAllImages
ImageLoader 是一個用于加載可執行文件的基類,它負責鏈接鏡像,但不關心具體文件格式,因為這些都交給子類去實現。每個可執行文件都會對應一個 ImageLoader實例。ImageLoaderMachO 是用于加載 Mach-O 格式文件的 ImageLoader 子類,而 ImageLoaderMachOClassic 和 ImageLoaderMachOCompressed 都繼承于 ImageLoaderMachO,分別用于加載那些 __LINKEDIT 段為傳統格式和壓縮格式的 Mach-O 文件。接下來就來到重頭戲了 reloadAllImages 了:
實例化主程序
這里我們看到有一行代碼:
// instantiate ImageLoader for main executablesMainExecutable = instantiateFromLoadedImage(mainExecutableMH, mainExecutableSlide, sExecPath);顯然,在這里我們的主程序被實例化了,我們進入這個方法內部:
這里相當于要為已經映射到主可執行文件中的文件創建一個 ImageLoader*。
從上面代碼我們不難看出這里真正執行的邏輯是 ImageLoaderMachO::instantiateMainExecutable 方法:
我們再進入 sniiffLoadCommands 方法內部:
通過注釋不難看出:sniiffLoadCommands 會確定此 mach-o 文件是否具有原始的或壓縮的 LINKEDIT 以及 mach-o 文件的 segement 的個數。
sniiffLoadCommands 完成后,判斷 LINKEDIT 是壓縮的格式還是傳統格式,然后分別調用對應的 instantiateMainExecutable 方法來實例化主程序。
加載任何插入的動態庫
鏈接庫
先是鏈接主二進制可執行文件,然后鏈接任何插入的動態庫。這里都用到了 link 方法,在這個方法內部會執行遞歸的 rebase 操作來修正 ASLR 偏移量問題。同時還會有一個 recursiveApplyInterposing 方法來遞歸的將動態加載的鏡像文件插入。
運行所有初始化程序
完成鏈接之后需要進行初始化了,這里會來到 initializeMainExecutable:
這里注意執行順序:
- 先為所有插入并鏈接完成的動態庫執行初始化操作
- 然后再為主程序可執行文件執行初始化操作
在 runInitializers 內部我們繼續探索到 processInitializers:
然后我們來到 recursiveInitialization:
然后我們來到 notifySingle:
箭頭所示的地方是獲取鏡像文件的真實地址。
我們全局搜索一下 sNotifyObjcInit 可以來到 registerObjCNotifiers:
接著搜索 registerObjCNotifiers:
此時,我們打開 libObjc 的源碼可以看到:
上面這一連串的跳轉,結果很顯然:dyld 注冊了回調才使得 libobjc 能知道鏡像何時加載完畢。
在 ImageLoader::recursiveInitialization 方法中還有一個 doInitialization 值得注意,這里是真正做初始化操作的地方。
doInitialization 主要有兩個操作,一個是 doImageInit,一個是 doModInitFunctions:
doImageInit 內部會通過初始地址 + 偏移量拿到初始化器 func,然后進行簽名的驗證。驗證通過后還要判斷初始化器是否在鏡像文件中以及 libSystem 庫是否已經初始化,最后才執行初始化器。
通知監聽 dyld 的 main
一切工作做完后通知監聽 dyld 的 main,然后為主二進制可執行文件找到入口,最后對結果進行簽名。
四、探索 _objc_init
我們直接通過 LLDB 大法來斷點調試 libObjc 中的 _objc_init,然后通過 bt 命令打印出當前的調用堆棧,根據上一節我們探索 dyld 的源碼,此刻一切的一切都是那么的清晰明了:
我們可以看到 dyld 的最后一個流程是 doModInitFunctions 方法的執行。
我們打開 libSystem 的源碼,全局搜索 libSystem_initializer 可以看到:
然后我們打開 libDispatch 的源碼,全局搜索 libdispatch_init 可以看到:
我們再搜索 _os_object_init:
完美~,_objc_init 在這里就被調用了。所以 _objc_init 的流程是
dyld -> libSystem -> libDispatch -> libObc -> _objc_init
五、總結
本文主要探索了 app 啟動之后 dyld 的流程,整個分析過程確實比較復雜,但在探索的過程中,我們不僅對底層源碼有了新的認知,同時對于優化我們 app 啟動也是有很多好處的。
作為一個開發者,有一個學習的氛圍跟一個交流圈子特別重要,這是一個我的iOS交流群:651612063 進群密碼123 ,不管你是小白還是大牛歡迎入駐 ,分享BAT,阿里面試題、面試經驗,討論技術, 大家一起交流學習成長!
https://jq.qq.com/?_wv=1027&k=Uk9k1Fz8 (二維碼自動識別)
總結
以上是生活随笔為你收集整理的app启动页数秒加载 代码_iOS 底层探索 - 应用加载的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: iscsi没有可用于使用快速连接登陆的目
- 下一篇: python 值传递还是引用传递_pyt