玩轉iOS「宏定義」

宏定義在C類語言中很是重要,由於宏是一種預編譯時的功能,所以其能夠比運行時更高層面的對程序流程進行控制。在初學宏定義的時候,你們可能都會有這樣一種感受:就是徹底替換麼,太簡單了。但若是你真這麼想,那你就太天真了,不說本身編寫宏,在Foundation框架中內置定義的許多宏要看明白也要費一番腦筋。本篇博客,總結了前輩的經驗,同時收集了一些編寫很是巧妙的宏進行分析,但願能夠幫助你們對宏定義有更加深入的理解,而且能夠將心得應用於實際開發中。面試

1、準備

宏的本質是預編譯時的替換,在開始正文以前,咱們須要先介紹一種觀察宏替換後結果的方法,這樣幫助咱們更方便的對宏最終的結果進行驗證與測試。Xcode開發工具自帶查看預編譯結果的功能,首先須要對工程編譯一遍,以後選擇工具欄中的Assistant選項,打開助手窗口,以下圖所示:編程

 

以後選擇窗口的Preprocess選項,便可打開預編譯結果窗口,能夠看到,宏被替換後的最終結果,以下圖所示:安全

 

後面,咱們將使用這種方式來對編寫的宏進行驗證。數據結構

2、關於「宏定義」

宏使用#define來進行定義,宏定義分爲兩種,一種是對象式宏,一種是函數式宏。對象式宏一般對來定義量值,在預編譯時,直接將宏名替換成對應的量值,函數式宏在定義時能夠設置參數,其做用與函數很相似。架構

例如,咱們能夠將π的值定義成一個對象式宏,在使用的時候,用有意義的宏名要比直接使用π的字面值方便不少,例如:框架

#import <Foundation/Foundation.h>
#define PI 3.1415926
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // insert code here...
        CGFloat res = PI * 3;
        NSLog(@"%f", res);
    }
    return 0;
}

函數式宏要更加靈活一些,例如對圓面積計算的方法,咱們就能夠將其定義成一個宏:函數

#define PI 3.1415926
#define CircleArea(r) PI * r * r
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // insert code here...
        CGFloat res = CircleArea(1);
        NSLog(@"%f", res);
    }
    return 0;
}

如今,有了這個面積計算宏咱們能夠更加方便的計算圓的面積了,看上去很完美,後面咱們就使用這個函數式宏爲例,來深刻理解宏的原理。工具

做爲一個開發者,有一個學習的氛圍跟一個交流圈子特別重要,這是一個個人點擊加入羣聊iOS交流羣:789143298 ,無論你是小白仍是大牛歡迎入駐 ,分享BAT,阿里面試題、面試經驗,討論技術,你們一塊兒交流學習成長!學習

 

3、從一個簡單的函數式宏提及

再來看下上面咱們編寫的計算面積的宏,正常狀況下好像沒什麼問題,可是須要注意,歸根結底宏並非函數,若是徹底把其做爲函數使用,咱們就可能會陷入一系列的陷阱中,好比這樣使用:開發工具

#define PI 3.1415926
#define CircleArea(r) PI * r * r
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // insert code here...
        CGFloat res = CircleArea(1 + 1);
        NSLog(@"%f", res);
    }
    return 0;
}

運行代碼,運算的結果並非半徑爲2個圓的面積,哪裏出了問題呢,咱們仍是先看下宏預編譯後的結果:

CGFloat res = 3.1415926 * 1 + 1 * 1 + 1;

一目瞭然了,因爲運算符的優先級問題致使了運算順序錯誤,在編程中,全部運算符優先級產生的問題均可以使用一種方式解決:用小括號。對CircleArea宏進行一下改造,以下:

#define CircleArea(r) (PI * (r) * (r))

對執行順序進行了強制的控制,代碼執行又恢復了正常,看上去好像是沒有問題了,如今就滿意了還爲時過早,例以下面這樣使用這個宏:

#import <Foundation/Foundation.h>
#define PI 3.1415926
#define CircleArea(r) PI * (r) * (r)
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // insert code here...
        int r = 1;
        CGFloat res = CircleArea(r++);
        NSLog(@"%f, %d", res, r);
    }
    return 0;
}

運行,發現結果又錯了,不只計算結果與咱們的預期不符,變量自加的的結果也不對了,咱們檢查其展開的結果:

CGFloat res = 3.1415926 * (r++) * (r++);

原來問題出在這裏,宏在展開的時候,將參數替換了兩次,因爲參數自己是一個自加表達式,因此被自加了兩次,產生了問題,那麼這個問題怎麼解決呢,C語言中有一種頗有用的語法,即便用大括號定義代碼塊,代碼塊會將最後一條語句的執行結果返回,修改上面宏定義以下:

#import <Foundation/Foundation.h>
#define PI 3.1415926
#define CircleArea(r)   \
({                      \
    typeof(r) _r = r;   \
    (PI * (_r) * (_r)); \
})
int main(int argc, const char * argv[]) {
    @autoreleasepool {

        int r = 1;
        CGFloat res = CircleArea(r++);
        NSLog(@"%f, %d", res, r);
    }
    return 0;
}

此次程序又恢復的了正常。可是,若是若是在調用宏是變量的名字與宏內的臨時變量產生了重名,災難就又發生了,例如:

#import <Foundation/Foundation.h>
#define PI 3.1415926
#define CircleArea(r)   \
({                      \
    typeof(r) _r = r;   \
    (PI * (_r) * (_r)); \
})
int main(int argc, const char * argv[]) {
    @autoreleasepool {

        int _r = 1;
        CGFloat res = CircleArea(_r);
        NSLog(@"%f, %d", res, _r);
    }
    return 0;
}

運行上面代碼,會發現宏內的臨時變量沒有被初始化成功。這確實難受,咱們在進一步,好比對臨時變量的名字作一些手腳,將其命名爲極其不容易重複的名字,其實系統內置的一個宏就是專門用來構造惟一性變量名的:COUNTER,這個宏是一個計數器,在編譯的時候會自動進行累加,再次對咱們編寫的宏進行改造,以下:

#import <Foundation/Foundation.h>
#define PI 3.1415926
#define PAST(A, B) A##B
#define CircleArea(r)   __CircleArea(r, __COUNTER__)
#define __CircleArea(r, v)      \
({                              \
    typeof(r) PAST(_r, v) = r;         \
    (PI * PAST(_r, v) * PAST(_r, v));     \
})
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int _r = 1;
        CGFloat res = CircleArea(_r);
        CGFloat res2 = CircleArea(_r);
        NSLog(@"%f, %f", res, res2);
    }
    return 0;
}

這裏改造後,咱們的宏就沒有那麼容易理解了,首先COUNTER在每次宏替換時都會進行自增,##是一種宏中專用的特殊符號,用來將參數拼接到一塊兒,可是須要注意,使用##符號拼接的若是是另一個宏,則其會阻止宏的展開,所以咱們定義了一個轉換宏PAST(A, B)來處理拼接。若是你一會兒不能理解爲何這樣就能夠解決宏展開的問題,你只須要記住這樣一條宏展開的原則:若是形參有使用#或##這種處理符號,則不會進行宏參數的展開,不然先展開宏參數,在展開當前宏。上面代碼最終預編譯的結果以下:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int _r = 1;
        CGFloat res = ({ typeof(_r) _r0 = _r; (3.1415926 * _r0 * _r0); });
        CGFloat res2 = ({ typeof(_r) _r1 = _r; (3.1415926 * _r1 * _r1); });
        NSLog(@"%f, %f", res, res2);
    }
    return 0;
}

一個簡單的計算圓面積的宏,爲了安全,咱們就進行了這麼多的處理,看來要用好宏,的確不容易。

4、編寫宏時的好習慣

經過前面的介紹,咱們知道,若是隨隨意意的編寫一個宏是很是不負責任的,看上去好像沒問題與在任何場景下使用都沒有問題是徹底不一樣的。在編寫宏時,咱們能夠刻意的去培養這樣幾個編碼習慣:

  • 參數與計算結果要加小括號

    這條原則應該沒必要多說了,前面的示例中就有演示,完整的添加小括號能夠避免不少因爲運算符優先級形成的異常問題。

  • 多語句功能性宏,要使用do-while包裹

    這條原則看上去有些莫名其妙,可是其很是重要,例如,咱們須要編寫一個自定義的LOG宏,在進行打印時添加一些自定義的信息,你或許會這樣寫:

#define LOG(string)     \
NSLog(@"自定義的信息");   \
NSLog(string);

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        LOG(@"info")
    }
    return 0;
}

運行代碼,目前貌似沒有問題,可是若是其和if語句進行結合,可能問題就來了:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        if (NO)
            LOG(@"info")
    }
    return 0;
}

運行代碼,仍是有一行LOG信息被輸出了,看下其預編譯後的結果以下:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        if (__objc_no)
            NSLog(@"自定義的信息"); NSLog(@"info");
    }
    return 0;
}

找到問題了,因爲if結構若是不加大括號進行規範,其默認做用域只有一句代碼,多寫大括號是不會出問題,所以編寫多語句宏時,加上大括號是一個好習慣,以下:

#define LOG(string)     \
{NSLog(@"自定義的信息");   \
NSLog(string);}

這樣解決了問題,可是並不完美,假設在使用時這樣寫:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        if (NO)
            LOG(@"NO");
        else
            LOG(@"YES");
    }
    return 0;
}

結果發現仍是會報錯,是因爲分號搗的鬼,預編譯結果以下:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        if (__objc_no)
            {NSLog(@"自定義的信息"); NSLog(@"NO");};
        else
            {NSLog(@"自定義的信息"); NSLog(@"YES");};
    }
    return 0;
}

咱們知道,像if,while,for這種語法結構塊的大括號後是不須要分號的,咱們爲了兼容單行if語句因爲宏的緣由被展開成多行的問題強行加了一個大括號上去,就產生這樣的問題了,解決它的一個好方法是真的將多行的宏轉化成單語句,do-whlie結構就能夠實現這種效果,修改宏以下:

#define LOG(string)     \
do {NSLog(@"自定義的信息");   \
NSLog(string);} while(0);
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        if (NO)
            LOG(@"NO")
        else
            LOG(@"YES");
    }
    return 0;
}

預編譯後:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        if (__objc_no)
            do {NSLog(@"自定義的信息"); NSLog(@"NO");} while(0);
        else
            do {NSLog(@"自定義的信息"); NSLog(@"YES");} while(0);;
    }
    return 0;
}

如今,不管外面怎麼使用,這個宏均可以正常工做了。

  • 對於不定參數的宏,藉助##符號來拼接參數

    在定義函數時,咱們能夠定義函數的參數爲不定個數參數,定義函數式宏時也相似,使用符號"..."能夠指定不定個數參數,例如對LOG宏進行調整,以下:

#define LOG(format, ...)     \
do {NSLog(@"自定義的信息");   \
NSLog(format, __VA_ARGS__);} while(0);
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        if (NO)
            LOG(@"%d", NO)
        else
            LOG(@"%d", YES);
    }
    return 0;
}

VA_ARGS也是一個內置的宏符號,則做用是表明宏定義中的可變參數「...」,須要注意,若是按照上面的寫法,若是咱們傳入的可變參數爲0個,會產生問題,其緣由也是因爲多了一個逗號,例如:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        if (NO)
            LOG(@"%d") // 這裏會被預編譯成NSLog(@"%d", )
        else
            LOG(@"%d", YES);
    }
    return 0;
}

解決方案是對可變參數進行一次##拼接,宏在使用##符號進行參數拼接時,若是後面的參數爲空,其會自動將前面的逗號去掉,以下:

#define LOG(format, ...)     \
do {NSLog(@"自定義的信息");   \
NSLog(format, ##__VA_ARGS__);} while(0);

5、特殊的宏符號與經常使用內置宏

有幾個特殊的符號可讓宏定義變得很是靈活,經常使用的特殊符號和特殊宏列舉以下:

  •  

    井號的做用是將參數字符串化,例如:

#define Test(p) #p

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Test(abc); // 預編譯後成爲  "abc";
    }
    return 0;
}

 

  •  

    雙井號咱們前面有使用過,其做用是對參數進行拼接,例如:

#define Test(a,b) a##b

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Test(1,2); // 預編譯後成爲  12;
    }
    return 0;
}
  • __TIME __

    可變參數宏中專用,表示全部傳入的可變參數。

  • __COUNTER __

    一個累加計數宏,經常使用來構造惟一變量名。

  • __LINE __

    記錄LOG信息時,經常使用的一個內置宏,預編譯時會將其替換爲當前的行號。

  • __FILE __

    記錄LOG信息時,經常使用的一個內置宏,預編譯時會將其替換爲當前文件的全路徑。

  • __FILE_NAME __

    記錄LOG信息時,經常使用的一個內置宏,預編譯時會將其替換爲當前的文件名。

  • __DATE __

    記錄LOG信息時,經常使用的一個內置宏,預編譯時會將其替換爲當前日期。

  • __TIME __

    記錄LOG信息時,經常使用的一個內置宏,預編譯時會將其替換爲當前時間。

6、宏的展開規則

經過前面的介紹,對於應用宏咱們已經沒有太大的問題,而且也瞭解了不少宏的使用技巧。這一小節將更深刻的對宏的替換規則進行討論。宏自己是支持嵌套的,例如:

#define M1(A) M2(A)
#define M2(A) A
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        M1(1);
    }
    return 0;
}

上面代碼中定義的兩個宏基本上是沒有意義的,M1宏替換後的結果是M2宏,M2宏最終被替換爲參數自己,從這個例子能夠看出,宏是能夠嵌套遞歸展開的,可是遞歸展開是有原則,不會出現無限遞歸,例如:

#define M1(A) M2(A)
#define M2(A) M1(A)
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        M1(1); // 最終展開爲 M1(1)
    }
    return 0;
}

宏的展開須要符合下面原則:

  1. 在展開宏的過程當中會先將參數進行展開,若是使用##對參數進行了拼接或使用#進行了處理,則此參數不會被展開。
  2. 在宏的展開過程當中,若是替換列表中出現了要被展開的宏,則此宏不會被展開。

上面的展開原則提到了替換列表,宏在展開過程當中會維護一個替換列表,展開的過程當中須要從參數到宏自己,從外層宏到內層宏一層一層的替換,每次替換的時候都會將被替換的宏名放入維護的替換列表中,再下一輪替換中,若是再次出現替換列表中出現過的宏名,則不會被再次替換。以咱們上面的代碼爲例進行分析:

  1. 首先M1宏在第一輪替換後,被替換成了M2,此時替換列表中放入宏名M1。
  2. M2依然是一個宏名,第二輪對M2進行替換,將其替換爲M1,再次將M2放入替換列表,此時替換列表中有宏名M1和M2。
  3. M1依然是宏名,可是替換列表中已經存在M1,此宏名再也不展開。

7、宏的妙用

這一小節,咱們要轉身成爲鑑賞家,來對不少實用的宏的巧妙案例進行分析與鑑賞。從這些優秀的使用案例中,能夠擴寬咱們對宏使用的思路。

  1. MIN與MAX

    Foundataion內置了一些經常使用的運算宏,如獲取兩個數的最大值、最小值、絕對值等等。以MAX宏爲例,這個宏的編寫基本涵蓋了函數式宏全部要注意的點,以下:

#define __NSX_PASTE__(A,B) A##B
#if !defined(MAX)
    #define __NSMAX_IMPL__(A,B,L) ({ __typeof__(A) __NSX_PASTE__(__a,L) = (A); __typeof__(B) __NSX_PASTE__(__b,L) = (B); (__NSX_PASTE__(__a,L) < __NSX_PASTE__(__b,L)) ? __NSX_PASTE__(__b,L) : __NSX_PASTE__(__a,L); })
    #define MAX(A,B) __NSMAX_IMPL__(A,B,__COUNTER__)
#endif

其中NSMAX_IMPL宏藉助計數COUNTER和拼接NSX_PASTE宏來構造惟一的內部變量名,咱們前面提供的示例宏的寫法也基本是參照這個系統宏來的。後面你們在編寫函數式宏的時候,均可以參照下這個宏的實現。

  1. NSAssert等

    NSAssert是斷言宏,在開發調試中常常會使用斷言來進行安全保障,這個宏的定義以下:

#define NSAssert(condition, desc, ...)  \
    do {                \
    __PRAGMA_PUSH_NO_EXTRA_ARG_WARNINGS \
    if (__builtin_expect(!(condition), 0)) {        \
            NSString *__assert_file__ = [NSString stringWithUTF8String:__FILE__]; \
            __assert_file__ = __assert_file__ ? __assert_file__ : @"<Unknown File>"; \
        [[NSAssertionHandler currentHandler] handleFailureInMethod:_cmd \
        object:self file:__assert_file__ \
            lineNumber:__LINE__ description:(desc), ##__VA_ARGS__]; \
    }               \
        __PRAGMA_POP_NO_EXTRA_ARG_WARNINGS \
    } while(0)

NSAssert宏定義中使用到了不定參數拼接消除逗號的技巧,而且是多行宏語句使用do-while進行優化的一個實踐。

  1. @weakify與@strongify

weakify與strongify是ReactCocoa中經常使用的兩個宏,用來處理循環引用問題。這兩個宏的定義很是巧妙,以weakify宏爲例,要看懂這個宏並非十分簡單,首先與這個宏相關的宏定義列舉以下:

#if DEBUG
#define rac_keywordify autoreleasepool {}
#else
#define rac_keywordify try {} @catch (...) {}
#endif

#define rac_weakify_(INDEX, CONTEXT, VAR) \
CONTEXT __typeof__(VAR) metamacro_concat(VAR, _weak_) = (VAR);

#define weakify(...) \
rac_keywordify \
metamacro_foreach_cxt(rac_weakify_,, __weak, __VA_ARGS__)

#define metamacro_foreach_cxt(MACRO, SEP, CONTEXT, ...) \
metamacro_concat(metamacro_foreach_cxt, metamacro_argcount(__VA_ARGS__))(MACRO, SEP, CONTEXT, __VA_ARGS__)

#define metamacro_argcount(...) \
metamacro_at(20, __VA_ARGS__, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1)
#define metamacro_at20(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19, ...) metamacro_head(__VA_ARGS__)
#define metamacro_at(N, ...) \
metamacro_concat(metamacro_at, N)(__VA_ARGS__)

#define metamacro_concat(A, B) \
metamacro_concat_(A, B)

#define metamacro_concat_(A, B) A ## B

#define metamacro_head(...) \
metamacro_head_(__VA_ARGS__, 0)

#define metamacro_foreach_cxt1(MACRO, SEP, CONTEXT, _0) MACRO(0, CONTEXT, _0)

#define metamacro_head_(FIRST, ...) FIRST

其中rac_keywordify區分DEBUG和RELEASE環境,在DEBUG環境下,其其實是建立了一個無用的autoreleasepool,消除前面的@符號,在RELEASE環境下,其會建立一個try-catch結構,用來消除參數警告。metamacro_foreach_cxt宏比較複雜,其展開過程以下:

// 第一步: 原始宏
metamacro_foreach_cxt(rac_weakify_,, __weak, obj)
// 第二步: 展開metamacro_foreach_cxt
metamacro_concat(metamacro_foreach_cxt, metamacro_argcount(obj))(rac_weakify_,, __weak, obj)
// 第三步: 展開metamacro_argcount      
metamacro_concat(metamacro_foreach_cxt, metamacro_at(20, obj, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1))(rac_weakify_,, __weak, obj)
// 第四步: 展開metamacro_at       
metamacro_concat(metamacro_foreach_cxt,metamacro_concat(metamacro_at, 20)(obj, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1))(rac_weakify_,, __weak, obj)
// 第五步:展開metamacro_concat       
metamacro_concat(metamacro_foreach_cxt,metamacro_at20(obj, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1))(rac_weakify_,, __weak, obj)
// 第六步:展開metamacro_at20        
metamacro_concat(metamacro_foreach_cxt,metamacro_head(1))(rac_weakify_,, __weak, obj)
// 第七步:展開metamacro_head        
metamacro_concat(metamacro_foreach_cxt,metamacro_head_(1, 0))(rac_weakify_,, __weak, obj)
// 第八步:展開metamacro_head_      
metamacro_concat(metamacro_foreach_cxt,1)(rac_weakify_,, __weak, obj)
// 第九步:展開metamacro_concat        
metamacro_foreach_cxt1(rac_weakify_,, __weak, obj)
// 第十步:展開metamacro_foreach_cxt1
rac_weakify_(0, __weak, obj)
// 第十一步:展開rac_weakify_
__weak __typeof__(obj) metamacro_concat(obj, _weak_) = (obj);
// 第十二步:展開metamacro_concat        
__weak __typeof__(obj) obj_weak_ = (obj);

strongify宏的展開與之相似。

  1. ParagraphStyleSet宏

    ParagraphStyleSet宏是YYLabel中提供的一個設置屬性字符串ParagraphStyle相關屬性的快捷方法,其中使用到的一個技巧是直接使用宏的形參做爲屬性名進行使用,使得各類屬性的設置都使用同一個宏便可完成,其定義以下:

#define ParagraphStyleSet(_attr_) \
[self enumerateAttribute:NSParagraphStyleAttributeName \
                 inRange:range \
                 options:kNilOptions \
              usingBlock: ^(NSParagraphStyle *value, NSRange subRange, BOOL *stop) { \
                  NSMutableParagraphStyle *style = nil; \
                  if (value) { \
                      if (CFGetTypeID((__bridge CFTypeRef)(value)) == CTParagraphStyleGetTypeID()) { \
                          value = [NSParagraphStyle yy_styleWithCTStyle:(__bridge CTParagraphStyleRef)(value)]; \
                      } \
                      if (value. _attr_ == _attr_) return; \
                      if ([value isKindOfClass:[NSMutableParagraphStyle class]]) { \
                          style = (id)value; \
                      } else { \
                          style = value.mutableCopy; \
                      } \
                  } else { \
                      if ([NSParagraphStyle defaultParagraphStyle]. _attr_ == _attr_) return; \
                      style = [NSParagraphStyle defaultParagraphStyle].mutableCopy; \
                  } \
                  style. _attr_ = _attr_; \
                  [self yy_setParagraphStyle:style range:subRange]; \
              }];

8、結語

宏看上去簡單,可是真的用好用巧卻並不容易,我想,最好的學習方式就是在實際應用中不斷的使用,不斷的琢磨與優化。若是能將宏的使用得心應手,必定會爲你的代碼能力帶來質的提高。

更多文章:

2020年iOS大廠面試題總結(一)
iOS學習棧(將持續更新)上
阿里、字節:一套高效的iOS面試題

推薦👇:

  • 020 持續更新,精品小圈子每日都有新內容,乾貨濃度極高。

  • 結實人脈、討論技術 你想要的這裏都有!

  • 搶先入羣,跑贏同齡人!(入羣無需任何費用)

  • (直接搜索羣號:789143298,快速入羣)
  • 點擊此處,與iOS開發大牛一塊兒交流學習

申請即送:

    • BAT大廠面試題、獨家面試工具包,

    • 資料免費領取,包括 數據結構、底層進階、圖形視覺、音視頻、架構設計、逆向安防、RxSwift、flutter,

       

       
相關文章
相關標籤/搜索