簡單深入了解block

用了block很長時間,也能避免相關(guān)的使用問題,想研究下大體底層實現(xiàn),看了很多的優(yōu)秀博客,這里寫一下自己的理解。

首先block用Apple文檔的話來說,“A block is an anonymous inline collection of code, and sometimes also called a "closure".
個人理解:block就是一個匿名內(nèi)聯(lián)的代碼集合,有時也叫他"closure"<閉包>
閉包是啥:看到網(wǎng)上一句經(jīng)典的描述,閉包就是能夠讀取其他函數(shù)內(nèi)部的函數(shù)。

通常來說,block都是一些簡短代碼片塊的封裝,適用作工作單元,通常用來做并發(fā)任務(wù)、遍歷、以及回調(diào)。
比如:
多線程的相關(guān)的操作,GCD蘋果都是以block的形式,等等
數(shù)組,字典的相關(guān)的遍歷,等等
網(wǎng)絡(luò)請求的相關(guān)回調(diào)之類的,界面跳轉(zhuǎn)傳值,等等
當前也有很多的三方框架也應(yīng)用很多的block,通過block進行異步傳值,進行事件響應(yīng)回調(diào)。

使用 clang -rewrite-objc 來深入了解一下block吧。
clang提供的中間代碼可以帶我們簡單的了解一下block。clang可以將我們寫完的OC代碼編譯成C++代碼。
1、首先進入程序的目錄

Paste_Image.png

2、執(zhí)行clang


Paste_Image.png

3、生成相關(guān)的cpp文件


Paste_Image.png

但是帶有
#import <UIKit/UIKit.h>

的類好像是轉(zhuǎn)不不成cpp文件的。

編譯的結(jié)果內(nèi)容比較多,一個簡單的main.m文件簡單的幾句代碼,編譯之后就大概有10萬多行。
很多語言都可以只實現(xiàn)編譯器前端,生成C中間代碼,然后利用現(xiàn)有的很多C編譯器后端。

通過代碼進行編譯查看

#import <Foundation/Foundation.h>
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        void (^myBlock1)() = ^{
            printf("hello world1111");
        };
        myBlock1();
        
        void (^myBlock2)() = ^{
            printf("hello world2222");
        };
        myBlock2();

    }
    return 0;
}

clang編譯結(jié)果如下

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int flags=0) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {

            printf("hello world1111");
        }

static struct __main_block_desc_0 {
  size_t reserved;
  size_t Block_size;
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};

struct __main_block_impl_1 {
  struct __block_impl impl;
  struct __main_block_desc_1* Desc;
  __main_block_impl_1(void *fp, struct __main_block_desc_1 *desc, int flags=0) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};
static void __main_block_func_1(struct __main_block_impl_1 *__cself) {

            printf("hello world2222");
        }

static struct __main_block_desc_1 {
  size_t reserved;
  size_t Block_size;
} __main_block_desc_1_DATA = { 0, sizeof(struct __main_block_impl_1)};
int main(int argc, const char * argv[]) {
    /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; 

        void (*myBlock1)() = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA));
        ((void (*)(__block_impl *))((__block_impl *)myBlock1)->FuncPtr)((__block_impl *)myBlock1);

        void (*myBlock2)() = ((void (*)())&__main_block_impl_1((void *)__main_block_func_1, &__main_block_desc_1_DATA));
        ((void (*)(__block_impl *))((__block_impl *)myBlock2)->FuncPtr)((__block_impl *)myBlock2);

    }
    return 0;
}

<1>、首先出現(xiàn)的結(jié)構(gòu)體就是__main_block_impl_0,可以看出是根據(jù)所在函數(shù)(main函數(shù))以及出現(xiàn)序列(第0個)進行命名的。
下面還有一個__main_block_impl_1,應(yīng)該是按照出現(xiàn)序列的(第1個)進行命名的。

<2>、__main_block_impl_0中包含了兩個成員變量和一個構(gòu)造函數(shù),成員變量分別是__block_impl結(jié)構(gòu)體和描述信息Desc,之后在構(gòu)造函數(shù)中初始化block的類型信息和函數(shù)指針等信息。從impl.isa = &_NSConcreteStackBlock;

<3>、接著出現(xiàn)的是 __main_block_func_0 函數(shù),即block對應(yīng)的函數(shù)體。該函數(shù)接受一個__cself參數(shù),即對應(yīng)的block自身。
再下面__main_block_desc_0描述的是block的相關(guān)信息,大小。

<4>、最下面展示的就是block的相關(guān)的調(diào)用和實現(xiàn)了。

   void (*myBlock1)() = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA));
        ((void (*)(__block_impl *))((__block_impl *)myBlock1)->FuncPtr)((__block_impl *)myBlock1);

        void (*myBlock2)() = ((void (*)())&__main_block_impl_1((void *)__main_block_func_1, &__main_block_desc_1_DATA));
        ((void (*)(__block_impl *))((__block_impl *)myBlock2)->FuncPtr)((__block_impl *)myBlock2);

執(zhí)行的時候?qū)嶋H上就是把block的相關(guān)信息傳了進去,也就是上面介紹的
__main_block_impl_0
__main_block_func_0
__main_block_desc_0

void (*myBlock1)() = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, 
&__main_block_desc_0_DATA));

以上是一個block的基本。


block可以訪問局部變量,甚至可以修改局部變量,那么我們來看一下是怎么實現(xiàn)的。
先看一個直接訪問局部變量的示例

#import <Foundation/Foundation.h>
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        int val = 0;
        void (^myBlock1)() = ^{
            printf("val === %d",val);
        };
        myBlock1();
        
    }
    return 0;
}

clang -rewrite-objc main.m 之后得到的轉(zhuǎn)化代碼

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  int val;
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _val, int flags=0) : val(_val) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
  int val = __cself->val; // bound by copy

            printf("val === %d",val);
        }

static struct __main_block_desc_0 {
  size_t reserved;
  size_t Block_size;
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};
int main(int argc, const char * argv[]) {
    /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; 

        int val = 0;
        void (*myBlock1)() = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, val));
        ((void (*)(__block_impl *))((__block_impl *)myBlock1)->FuncPtr)((__block_impl *)myBlock1);

    }
    return 0;
}

可以看到__main_block_impl_0中多了一個變量,val。
在實現(xiàn)中__main_block_func_0多了一個

int val = __cself->val; // bound by copy

__cself->val,應(yīng)該是類似于self.val。直接把val作為一個屬性來進行操作。 bound by copy這個注釋標識代表了,val是被拷貝過來的,內(nèi)存地址和上面的val 是不一樣的。

int val = 0;
        printf(" \nblock外面 %p",&val);
        void (^myBlock1)() = ^{
            printf("\nval === %d",val);
            printf("\nblock里面 %p",&val);
            
        };
        myBlock1();
        printf("\nblock外面 %p\n",&val);

打印的結(jié)果

block外面 0x7fff5fbff78c
val === 0
block里面 0x1005000f0
block外面 0x7fff5fbff78c

里外的val的對應(yīng)的內(nèi)存地址已經(jīng)發(fā)生變化了,但是當block調(diào)用完畢之后,val的地址沒有發(fā)生變化,也就是說,在block里面使用val的時候復(fù)制了一個新的地址進行使用了。也就能理解在block內(nèi)部修改val是修改不了的,因為val在block的地址和外面的不一樣,里面是一個臨時copy的一個全新地址的參數(shù)。

但是想直接修改局部變量的時候報錯了,提示要用__block來進行修飾val。

Paste_Image.png

那么,為什么這個時候不能給val進行賦值呢?


摘自網(wǎng)上的一段解釋,理解一下。
因為main函數(shù)中的局部變量val和函數(shù)__main_block_func_0不在同一個作用域中,調(diào)用過程中只是進行了值傳遞。當然,在上面代碼中,我們可以通過指針來實現(xiàn)局部變量的修改。不過這是由于在調(diào)用__main_block_func_0時,main函數(shù)棧還沒展開完成,變量val還在棧中。但是在很多情況下,block是作為參數(shù)傳遞以供后續(xù)回調(diào)執(zhí)行的。通常在這些情況下,block被執(zhí)行時,定義時所在的函數(shù)棧已經(jīng)被展開,局部變量已經(jīng)不在棧中了(block此時在哪里?),再用指針訪問就……


當時看完了有幾個比較疑惑的點。
1、"main函數(shù)中的局部變量val和函數(shù)__main_block_func_0不在同一個作用域中"。為什么不在一個作用域。
val 的作用域是main函數(shù),__main_block_func_0是全局的

static void __main_block_func_0

通過這個可以看出。
val只是一個局部變量
2、"main函數(shù)棧還沒展開完成",這句話啥意思?展開什么意思?
展開就是釋放的意思,就是說,main函數(shù)被釋放了。
3、"當然,在上面代碼中,我們可以通過指針來實現(xiàn)局部變量的修改",這個怎么實現(xiàn)

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int val = 0;
        int *bbb = &val;
        printf(" \nblock外面 %p   val = %d",&val,val);
        void (^myBlock1)() = ^{
            *bbb = 30;
            printf("\nblock里面 %p   val = %d",&val,val);
            
        };
        printf("\nblock外面111 %p   val = %d",&val,val);
        myBlock1();
        printf("\nblock外面222 %p   val = %d\n",&val,val);
        
    }
    return 0;
}

打印的結(jié)果

block外面 0x7fff5fbff76c   val = 0
block外面111 0x7fff5fbff76c   val = 0
block里面 0x100107508   val = 0
block外面222 0x7fff5fbff76c   val = 30

最終的結(jié)果可以看到 val的地址沒有發(fā)生變化,除了在block里面<block里面 0x100107508 val = 0>,這個打印的其實不是真正的val,是block copy的val,所以還是0。
修改了val的值,并且沒有修改val的地址。


來看一下我們熟悉的解決方法
添加__block修飾val對象。并且探索一下,__block怎么實現(xiàn)的。

#import <Foundation/Foundation.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        
        __block int val = 0;
        printf(" \nblock外面 %p   val = %d",&val,val);
        void (^myBlock1)() = ^{
            
            printf("\nblock里面 %p   val = %d",&val,val);
            
        };
        printf("\nblock外面111 %p   val = %d",&val,val);
        myBlock1();
        printf("\nblock外面222 %p   val = %d\n",&val,val);
        
    }
    return 0;
}

打印結(jié)果,內(nèi)存地址徹底的被修改了。執(zhí)行void (^myBlock1)()的時候就被修改了

block外面 0x7fff5fbff738   val = 0
block外面111 0x100102088   val = 0
block里面 0x100102088   val = 0
block外面222 0x100102088   val = 0

通過clang看一下情況

struct __Block_byref_val_0 {
  void *__isa;
__Block_byref_val_0 *__forwarding;
 int __flags;
 int __size;
 int val;
};

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  __Block_byref_val_0 *val; // by ref
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, __Block_byref_val_0 *_val, int flags=0) : val(_val->__forwarding) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
  __Block_byref_val_0 *val = __cself->val; // bound by ref

            printf("\nblock里面 %p   val = %d",&(val->__forwarding->val),(val->__forwarding->val));

        }
static void __main_block_copy_0(struct __main_block_impl_0*dst, struct __main_block_impl_0*src) {_Block_object_assign((void*)&dst->val, (void*)src->val, 8/*BLOCK_FIELD_IS_BYREF*/);}

static void __main_block_dispose_0(struct __main_block_impl_0*src) {_Block_object_dispose((void*)src->val, 8/*BLOCK_FIELD_IS_BYREF*/);}

static struct __main_block_desc_0 {
  size_t reserved;
  size_t Block_size;
  void (*copy)(struct __main_block_impl_0*, struct __main_block_impl_0*);
  void (*dispose)(struct __main_block_impl_0*);
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0), __main_block_copy_0, __main_block_dispose_0};
int main(int argc, const char * argv[]) {
    /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; 

        __attribute__((__blocks__(byref))) __Block_byref_val_0 val = {(void*)0,(__Block_byref_val_0 *)&val, 0, sizeof(__Block_byref_val_0), 0};
        printf(" \nblock外面 %p   val = %d",&(val.__forwarding->val),(val.__forwarding->val));
        void (*myBlock1)() = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, (__Block_byref_val_0 *)&val, 570425344));
        printf("\nblock外面111 %p   val = %d",&(val.__forwarding->val),(val.__forwarding->val));
        ((void (*)(__block_impl *))((__block_impl *)myBlock1)->FuncPtr)((__block_impl *)myBlock1);
        printf("\nblock外面222 %p   val = %d\n",&(val.__forwarding->val),(val.__forwarding->val));

    }
    return 0;
}

來看一下和val在沒有添加__block修飾的時候的clang出來的代碼的主要區(qū)別

1、由第一個成員__isa指針也可以知道** __Block_byref_val_0也可以是NSObject。
第二個成員
__forwarding指向自己,為什么要指向自己?指向自己是沒有意義的,只能說有時候需要指向另一個__Block_byref_val_0結(jié)構(gòu)。后面我們揭曉__forwarding**。
最后一個成員是目標存儲變量val。

struct __Block_byref_val_0 {
  void *__isa;
__Block_byref_val_0 *__forwarding;
 int __flags;
 int __size;
 int val;
};

2、__main_block_impl_0中也發(fā)生了變化
int val;變成了現(xiàn)在的*__Block_byref_val_0 val; // by ref

3、** __main_block_func_0中和之前也不一樣了
int val = __cself->val; // bound by copy
變成了現(xiàn)在的
__Block_byref_val_0 val = __cself->val; // bound by ref
__Block_byref_val_0指針類型變量val通過其成員變量
__forwarding
*指針來操作另一個成員變量。

4、** __main_block_desc_0**中多了兩個東西

 void (*copy)(struct __main_block_impl_0*, struct __main_block_impl_0*);
  void (*dispose)(struct __main_block_impl_0*);

從上面的clang編譯出來的代碼可以看到,block被轉(zhuǎn)化成了__main_block_impl_0結(jié)構(gòu)體實例,該實例持有__Block_byref_val_0結(jié)構(gòu)體實例的指針。
看一下 val 在block中的調(diào)用的情況

static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
  __Block_byref_val_0 *val = __cself->val; // bound by ref

            printf("\nblock里面 %p   val = %d",&(val->__forwarding->val),(val->__forwarding->val));

        }

通過__cself找到結(jié)構(gòu)體__Block_byref_val_0,然后通過__forwarding找到結(jié)構(gòu)體的成員val。成員變量val是該實例自身持有的變量,指向的是原來的局部變量。如圖所示:
<a >圖片來自</a>

Paste_Image.png

但是還是存在問題
<1>、__Block_byref_val_0類型的變量對應(yīng)的val仍然在棧上,當block執(zhí)行回調(diào)的時候,val所對用的棧被釋放了怎么辦?
<2>、為什么訪問val還要通過__forwarding?不直接修修改或者訪問val呢?

存儲域
上面的clang出的代碼可以看出。 isa指向的是 _NSConcreteStackBlock,還有另外的兩個類似的
_NSConcreteStackBlock 保存在棧中的block,出棧時會被銷毀
_NSConcreteGlobalBlock 全局的靜態(tài)block,不會訪問任何外部變量
_NSConcreteMallocBlock 保存在堆中的block,當引用計數(shù)為0時會被銷毀

上面我們的代碼,blcok是在棧上生成的,現(xiàn)在創(chuàng)建一個_NSConcreteGlobalBlock類型的block

#import <Foundation/Foundation.h>

void (^myBlock)()=^{
    
    printf("block");
};

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // insert code here...
        
        myBlock();
    }
    return 0;
}

clang -rewrite-objc main.m之后的代碼是

struct __myBlock_block_impl_0 {
  struct __block_impl impl;
  struct __myBlock_block_desc_0* Desc;
  __myBlock_block_impl_0(void *fp, struct __myBlock_block_desc_0 *desc, int flags=0) {
    impl.isa = &_NSConcreteGlobalBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};
static void __myBlock_block_func_0(struct __myBlock_block_impl_0 *__cself) {

    printf("block");
}

static struct __myBlock_block_desc_0 {
  size_t reserved;
  size_t Block_size;
} __myBlock_block_desc_0_DATA = { 0, sizeof(struct __myBlock_block_impl_0)};
static __myBlock_block_impl_0 __global_myBlock_block_impl_0((void *)__myBlock_block_func_0, &__myBlock_block_desc_0_DATA);
void (*myBlock)()=((void (*)())&__global_myBlock_block_impl_0);

int main(int argc, const char * argv[]) {
    /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; 

        ((void (*)(__block_impl *))((__block_impl *)myBlock)->FuncPtr)((__block_impl *)myBlock);
    }
    return 0;
}

可以看到

 impl.isa = &_NSConcreteGlobalBlock;

分配在全局的block,當超出變量的作用域的時候,依然可以通過指針進行安全的訪問。但是在棧上面的block,如果他所屬于的變量的作用域結(jié)束,那么該block的作用域結(jié)束也就結(jié)束了。同樣的__block修飾的變量也分配在棧上,當超過該變量的作用域時,該__block修飾的變量也會被廢棄。

這個時候就需要一個在堆上面block,生命周期由我們自己控制。
_NSConcreteMallocBlock登場。
這個時候就會將block__block修飾的變量,從棧上復(fù)制到堆上面。那么,棧上的block就可以超出所屬的變量的作用域了,那么復(fù)制到堆上面的block以及__block所修飾的變量仍然可以進行操作。

復(fù)制到堆上面的block也就變成

 impl.isa = &_NSConcreteMallocBlock;

而此時 結(jié)構(gòu)體中的__forwarding就發(fā)揮了作用,保證能訪問從棧上拷貝到堆上的__block修飾的變量。

我們一般可以使用copy方法手動將 Block 或者 __block變量從棧復(fù)制到堆上。比如我們把Block做為類的屬性訪問時,我們一般把該屬性設(shè)為copy。有些情況下我們可以不用手動復(fù)制,比如Cocoa框架中使用含有usingBlock方法名的方法時,或者GCD的API中傳遞Block時。

當一個Block被復(fù)制到堆上時,與之相關(guān)的__block變量也會被復(fù)制到堆上,此時堆上的Block持有相應(yīng)堆上的__block變量。當堆上的__block變量沒有持有者時,它才會被廢棄。(這里的思考方式和objc引用計數(shù)內(nèi)存管理完全相同。)

當棧上的__block修飾的變量被復(fù)制到了堆上之后,那么之后訪問堆上的變量就通過val->__forwarding->val了。

讓我們來看一下上面的第4點不同,多了的那兩句話,此時main_block_desc_0多了兩個成員函數(shù),分別是** copy dispose分別指向__main_block_copy__0__main_block_dispose__0**

當block從棧上被拷貝到堆上的時候,會調(diào)用__main_block_copy_0將__block類型的成員變量val從棧上復(fù)制到堆上;而當block被釋放時,相應(yīng)地會調(diào)用__main_block_dispose_0來釋放__block類型的成員變量val。

這時候,__forwarding的作用就體現(xiàn)出來了:當一個__block變量從棧上被復(fù)制到堆上時,棧上的那個__Block_byref_val_0結(jié)構(gòu)體中的__forwarding指針也會指向堆上的結(jié)構(gòu)。

<a >圖片來自</a>

Paste_Image.png

__block可以指定任何的局部變量,上面的代碼有如下代碼

static void __main_block_copy_0(struct __main_block_impl_0*dst, struct __main_block_impl_0*src) {_Block_object_assign((void*)&dst->val, (void*)src->val, 8/*BLOCK_FIELD_IS_BYREF*/);}

static void __main_block_dispose_0(struct __main_block_impl_0*src) {_Block_object_dispose((void*)src->val, 8/*BLOCK_FIELD_IS_BYREF*/);}

當block從棧上復(fù)制到堆上面的時候,會使用__main_block_copy該方法持有變量(相當于retain),當堆上面的block被廢棄的時候,就會使用__main_block_dispose釋放__block修飾的變量(相當于release)。

我們一開始用的demo,block一直實在棧上的,聲明的局部變量也是在棧上的,他們都在一個方法中,生命周期的話都依據(jù)所持有的方法。方法釋放了,局部變量和block也玩兒完,但是為什么這個時候局部變量想在block中修改還是必須得添加__block呢?

猜想,可能這是蘋果指定的規(guī)則,block是作為參數(shù)傳遞以供后續(xù)回調(diào)執(zhí)行的,block被傳遞出去了,局部變量持有者可能因為沒啥用了就被釋放了,那么局部變量也就被釋放了,再在block中修改局部變量就危險了。所以,不管在什么時候,blcok中修改局部變量都得添加__block來進行修飾。

理論部分說完了,來玩一下斷點看一下情況吧。

Paste_Image.png

不對啊,和之前說好的不一樣啊,不應(yīng)該是在棧上的嗎?
不應(yīng)該是clang以后的_NSConcreteStackBlock嗎?難道上面的理論都不成立?網(wǎng)上肯定不只有我瞎扯。
看到大神的博客,安心了。
http://blog.devtang.com/2013/07/28/a-look-inside-blocks/#NSConcreteGlobalBlock__u7C7B_u578B_u7684_block__u7684_u5B9E_u73B0
在 ARC 開啟的情況下,將只會有 NSConcreteGlobalBlock 和 NSConcreteMallocBlock 類型的 block。
原本的 NSConcreteStackBlock 的 block 會被 NSConcreteMallocBlock 類型的 block 替代。證明方式是以下代碼在 XCode 中,會輸出 <NSMallocBlock: 0x100109960>
。在蘋果的 官方文檔 中也提到,當把棧中的 block 返回時,不需要調(diào)用 copy 方法了。
由于 ARC 已經(jīng)能很好地處理對象的生命周期的管理,這樣所有對象都放到堆上管理,對于編譯器實現(xiàn)來說,會比較方便。

如有失誤請各位路過大神即時指點,或有更好的做法,也請指點一二,在下感激不盡。

參考的網(wǎng)址:
http://www.cocoachina.com/ios/20150106/10850.html
http://blog.devtang.com/2013/07/28/a-look-inside-blocks/
http://blog.csdn.net/jasonblog/article/details/7756763
http://blog.csdn.net/hherima/article/details/38620175
http://www.dreamingwish.com/articlelist/category/toturial

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容