Block背後的數據結構及變量截取

本文的內容主要是基於Clang編譯器的官方文檔所寫。

Clang

先說些題外話,什麼是Clang?Clang是C++編寫的編譯器。我們知道,我們平常代碼所寫的任何程序,最終都需要通過編譯器轉換成與語言無關的機器二進制代碼。而Clang,則是支持/C++/Objective-C/Objective-C++的編譯器。那我們在做OC開發時,可能也會聽說LLVM編譯器,那麼Clang和LLVM之間是什麼關係呢?

它們的關係如下圖所示:
在這裏插入圖片描述

Clang是編譯器的前端,它會分析具體的編程語言,然後用於生成與機器無關的中間代碼。而LLVM是編譯器的後端,與具體編程語言無關,而是會去分析統一的中間代碼,生成符合對應機器的目標程序。

這樣拆分前端後端的好處在於,前後端可以獨立的替換,便於編譯器的優化。

關於Clang,我們瞭解這些就足夠了。

Block的本質

回到Block上來。我們在使用Block語法時,總會感覺到有些奇怪:

^{
      NSLog(@"Hello");
 };

這麼一個^{}是什麼鬼?似乎在別的語言中也沒有見過這麼個關鍵字定義。其實,^{}對於Clang編譯器來說,僅僅是一個語言標記,它會告訴Clang,這裏我需要定義一個Block類型的結構體。 而Clang發現這個語言標記時,會將^{}這麼一個奇怪的定義,轉換爲C語言中的結構體。經過Clang轉換後的Block,其形式是這樣的:

struct Block_literal_1 {
	// 第一部分. Block基本信息以及 invoke函數指針
    void *isa; // initialized to &_NSConcreteStackBlock or &_NSConcreteGlobalBlock
    int flags;
    int reserved;
    void (*invoke)(void *, ...);
    
    // 第二部分. Block descriptor指針
    struct Block_descriptor_1 {
    unsigned long int reserved;         // NULL
        unsigned long int size;         // sizeof(struct Block_literal_1)
        // optional helper functions
        void (*copy_helper)(void *dst, void *src);     // IFF (1<<25)
        void (*dispose_helper)(void *src);             // IFF (1<<25)
        // required ABI.2010.3.16
        const char *signature;                         // IFF (1<<30)
    } *descriptor;
    
	// 第三部分. Block所截取的外部變量(如果有的話)
    // imported variables
};

筆者將Block結構體定義分成了三個部分:

  • Block基本信息以及 invoke函數指針
  • Block descriptor指針
  • Block所截取的外部變量

在這裏我們得出結論:Block的本質是一個C語言的struct

Block對應的結構體

上面探討了Block的本質是一個struct,接下來我們就來詳細看一下這個 Block struct的定義。

Block基本信息以及Block descriptor

struct Block_literal_1 {
	// 第一部分. Block基本信息以及 invoke函數指針
    void *isa; // initialized to __NSGlobalBlock__&__NSMallocBlock__&__NSStackBlock__ 
    int flags;
    int reserved;
    void (*invoke)(void *, ...);
    
     // 第二部分. Block descriptor指針
    struct Block_descriptor_1 {
    unsigned long int reserved;         // NULL
        unsigned long int size;         // sizeof(struct Block_literal_1)
        // optional helper functions
        void (*copy_helper)(void *dst, void *src);     // IFF (1<<25)
        void (*dispose_helper)(void *src);             // IFF (1<<25)
        // required ABI.2010.3.16
        const char *signature;                         // IFF (1<<30)
    } *descriptor;
    ...
};

我們先來看Block struct的第一部分。
當我們聲明一個Block時,對應的Block struct會被如下初始化:

  1. 系統會聲明並初始化一個Block descriptor結構體。初始化Block descriptor步驟如下
    a. Block descriptor 的size部分會被設置爲Block結構體的大小
    b. copy_helper 和 dispose_helper函數指針會被設置爲對應的函數指針(如果需要這兩個helper 函數的話)

  2. 系統初始化Block 結構體。 初始化Block 結構體的步驟如下:
    a. isa 部分會被設置爲__NSGlobalBlock__/__NSMallocBlock__/__NSStackBlock__ 所對應的地址。
    b. flags 會被置爲對應的flag數值。比如,如果Block struct需要copy,dispose helper函數時,響應的flag會被置位。同時,flags還有標誌Block ABI 版本的功能。
    c. 設置invoke函數指針指向對應的函數。該函數的第一個參數是Block struct本身的指針,而其餘的參數則是Block執行時外部要傳入的參數(如果有的話)

舉個例子,對於下面的Block:

^ { printf("hello world\n"); }

Clang會創建如下內容:

struct __block_literal_1 {
    void *isa;
    int flags;
    int reserved;
    void (*invoke)(struct __block_literal_1 *);
    struct __block_descriptor_1 *descriptor;
};

void __block_invoke_1(struct __block_literal_1 *_block) {
    printf("hello world\n");
}

static struct __block_descriptor_1 {
    unsigned long int reserved;
    unsigned long int Block_size;
} __block_descriptor_1 = { 0, sizeof(struct __block_literal_1) };

那麼Block struct將會如下被初始化:

struct __block_literal_1 _block_literal = {
     &__NSGlobalBlock__,
     (1<<29), <uninitialized>,
     __block_invoke_1,
     &__block_descriptor_1
};

這是Clang文檔給出的官方例子,但是我們這裏不要去究竟flags究竟是設置的什麼,因爲根據本人的測試,其flags的值並不是1<<29。

這裏有個問題,就是什麼時候isa會被設爲__NSGlobalBlock__/__NSMallocBlock__/__NSStackBlock__ 呢?

  • 當Block中沒有引用外部變量,或引用了全局變量,const 標量或static變量時,Block的isa會被設置爲__NSGlobalBlock__。 這時的Block生命週期是伴隨程序始終的。
  • __NSStackBlock__ 表示這個block, 是在棧上面分配的,出了棧就會消亡。使用了外部棧變量,就會是__NSStackBlock__ 類型。
  • __NSMallocBlock__ 表示Block複製到堆上面了,可以存儲下來,以後使用。當Block引用了外部的OC對象,Block對象或用__block修飾的變量時,Block會被設置爲__NSMallocBlock__ 類型。這裏有一點要注意,在ARC的情況下。只要將block賦值給變量,就自動幫你複製了。也就是說,如果將一個棧上的block賦值給另一個block變量,則被賦值的block變量類型是 __NSMallocBlock__ 類型。

如下面代碼:

 	int a = 13;
    NSLog(@"block type is %@", NSStringFromClass([^{NSLog(@"%d", a);} class]));
    blockType1 blk2 = ^{
        NSLog(@"%d", a);
    };
    NSLog(@"block type is %@", NSStringFromClass([blk2 class]));

輸出爲:
在這裏插入圖片描述

而對於const類型的引用,

 	const int a = 13;  // 這裏是const引用
    NSLog(@"block type is %@", NSStringFromClass([^{NSLog(@"%d", a);} class]));
    blockType1 blk2 = ^{
        NSLog(@"%d", a);
    };
    NSLog(@"block type is %@", NSStringFromClass([blk2 class]));

輸出爲:
在這裏插入圖片描述

Block的外部變量截取

理解Block的關鍵,在於理解Block是如何處理外部變量的。

我們先來想一想,Block中會截取那些類型的外部變量:

  • 全局/靜態變量
  • 自動存儲類型
  • Block類型
  • NSObject類型
  • __block修飾的變量

截取全局/靜態類型變量

對於全局/靜態變量,Block會直接引用這類變量,不會copy。 例如,

static int a = 13;
@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    NSLog(@"Outside Block, static int a address is %p", &a);
   ^{
        NSLog(@"Inside Block, static int a address is %p", &a);
    }();
   
}

輸出爲:
在這裏插入圖片描述

在Block 外和Block內,static int a的地址是一樣的,Block並沒有做特殊的處理。

截取自動存儲類型變量

所謂自動存儲類型,指的是auto類型。其實在函數中的局部變量,不加特殊聲明,都是auto變量,但是關鍵字auto可以被省略。這些變量在函數被調用時分配存儲方式,函數調用結束後這些存儲空間就被釋放了。
我們可以理解爲棧上的變量(Block類型、__block、NSObject類型除外)。

Variables of auto storage class are imported as const copies.

也就是說,auto類型會在Block中用const copy一份。也就是說Block內,外是完全不同的兩個變量。

例如:

	int b = 12;
    NSLog(@"Outside Block, address of int b is %p", &b);
   ^{
        NSLog(@"Inside Block, address of int b is %p", &b);
    }();

輸出爲:
在這裏插入圖片描述

可以看到,在Block外和Block內部,表面上同樣的b變量,其地址是不一樣的。究其原因,就是因爲在Block內部,系統會默默的const copy一份b。

這時候,Block的數據結構是這樣的:

int x = 10;
void (^vv)(void) = ^{ printf("x is %d\n", x); }
x = 11;
vv();
struct __block_literal_2 {
    void *isa;
    int flags;
    int reserved;
    void (*invoke)(struct __block_literal_2 *);
    struct __block_descriptor_2 *descriptor;
    const int x;   // 這裏會有一份const copy
};
struct __block_literal_2 __block_literal_2 = {
      &_NSConcreteStackBlock,
      (1<<29), <uninitialized>,
      __block_invoke_2,
      &__block_descriptor_2,
      x
 };

一般的,對於標量類型(int, float, bool等基本類型),struct,unions和函數指針類型,都會採用const copy的方式,將Block外部的變量拷貝到Block內部

這裏需要注意一點,在iOS系統中,當我們把一個stack 上的Block賦值給一個Block變量時:

void (^vv)(void) = ^{ printf("x is %d\n", x); }

會默認調用Block的copy方法,即,上面實際上是如下代碼:

void (^vv)(void) = [^{ printf("x is %d\n", x); } copy];

這樣得到的vv,是一個在堆上的Block變量。這時候再輸出vv中x的地址,會得到一個堆上的地址。

因此,我們在做實驗的時候,不要輸出對拷貝後的Block中變量地址,而應該直接輸出Block中的地址:

 ^{
        NSLog(@"Inside Block, static int a address is %p", &a);
    }();

上面代碼中並沒有賦值,因此會輸出棧上的a的const copy地址。

截取Block類型變量

對於截取Block類型的變量,在Block內部,同樣會保留其const copy。
如下代碼:

 	int a4 = 13;
    void (^existingBlock)(void) = ^{NSLog(@"Hello %d", a4);};
    NSLog(@"Outside Block, address of block pointer address is %p, block address is %p", &existingBlock, existingBlock);
    ^{
    NSLog(@"Inside Block, address of block pointer address is %p, block address is %p", &existingBlock, existingBlock);}();

    blockType1 blk = existingBlock;
    blk();

輸出爲:
在這裏插入圖片描述

可以看到在Block外部和內部,existingBlock分別是不同的地址。
但是,這裏爲了copy 外部Block到Block內部,還需要兩個輔助函數:copy_helperdispose_helper 。這兩個函數用來將stack上的Block pointer拷貝到堆上。

這裏需要注意的是,我們所聲明的Block變量existingBlock,是一個指向Block類型的指針,而不是Block本身。正如同NSObject *obj = [NSObject new]一樣,obj是一個指向NSObject的指針,而不是NSObject類型變量

下面是Clang文檔的例子:

void (^existingBlock)(void) = ...;
void (^vv)(void) = ^{ existingBlock(); }
vv();

struct __block_literal_3 {
   ...; // existing block
};

struct __block_literal_4 {
    void *isa;
    int flags;
    int reserved;
    void (*invoke)(struct __block_literal_4 *);
    struct __block_literal_3 *const existingBlock;
};

void __block_invoke_4(struct __block_literal_2 *_block) {
   __block->existingBlock->invoke(__block->existingBlock);
}

void __block_copy_4(struct __block_literal_4 *dst, struct __block_literal_4 *src) {
     //_Block_copy_assign(&dst->existingBlock, src->existingBlock, 0);
     _Block_object_assign(&dst->existingBlock, src->existingBlock, BLOCK_FIELD_IS_BLOCK);
}

void __block_dispose_4(struct __block_literal_4 *src) {
     // was _Block_destroy
     _Block_object_dispose(src->existingBlock, BLOCK_FIELD_IS_BLOCK);
}

static struct __block_descriptor_4 {
    unsigned long int reserved;
    unsigned long int Block_size;
    void (*copy_helper)(struct __block_literal_4 *dst, struct __block_literal_4 *src);
    void (*dispose_helper)(struct __block_literal_4 *);
} __block_descriptor_4 = {
    0,
    sizeof(struct __block_literal_4),
    __block_copy_4,
    __block_dispose_4,
};

這時候Block的數據結構是:

struct __block_literal_4 _block_literal = {
      &_NSConcreteStackBlock,
      (1<<25)|(1<<29), <uninitialized>
      __block_invoke_4,
      & __block_descriptor_4
      existingBlock,
};

截取NSObject類型變量

在Clang中,NSObject類型變量被當做__attribute__((NSObject))類型。和Block類型一樣,Block截取NSObject對象時,同樣會做一份const copy。
比如:

@interface MyObject : NSObject
- (void)sayMyObjectAddress
@end

@implementation MyObject
- (void)sayMyObjectAddress {
    NSLog(@"Instance pointer address is %p, Instance address is %p", &self, self);
}
@end

 	MyObject *obj = [MyObject new];
    [obj sayMyObjectAddress];
    ^{
        [obj sayMyObjectAddress];
    }();

輸出爲:
在這裏插入圖片描述

可以看到,當Block對NSObject做const copy時,僅是做了淺拷貝,並沒有複製指針所指向的內容,僅僅是const copy了指針。因此,這裏的self指針地址是改變了,而self指針所指向的地址都是同一個。

就像上面Block類型變量的例子,是同一個道理。

而對於NSObject類型,同樣需要兩個copy helper函數:

void __block_copy_foo(struct __block_literal_5 *dst, struct __block_literal_5 *src) {
     _Block_object_assign(&dst->objectPointer, src-> objectPointer, BLOCK_FIELD_IS_OBJECT);
}

void __block_dispose_foo(struct __block_literal_5 *src) {
     _Block_object_dispose(src->objectPointer, BLOCK_FIELD_IS_OBJECT);
}

截取__block修飾的變量

鑑於我們上面所說的const copy,因此對於在Block中對於其截取變量的任何改變,都是不被允許的。如果我們要修改Block內部的值,編譯器就會提示如下錯誤:
在這裏插入圖片描述

如果需要在Block內部修改所截取的外部變量,需要在外部變量上加上__block修飾符。我們將上面代碼改成下面的形式,則會順利編譯通過:

	__block int b = 13;
    NSLog(@"Outside Block, address of __block int b is %p, b = %d", &b, b);
    blockType1 blk = ^{
        b++;
        NSLog(@"Inside Block, address of __block int b is %p, b = %d", &b, b);
    };
    blk();
    NSLog(@"Now b = %d", b);

輸出爲:
在這裏插入圖片描述

這裏會發現一個有意思的現象,雖然在Block外部和內部,b的地址並不一樣,也就是在Block外部和內部,其實有兩個不同的b。但是當我們在Block內部修改b的值時(b++),然後在Block外部再輸出b的值時,發現外部的b也同樣被修改了!

之所以會這樣,與Clang對於__block類型變量的處理有關。

當變量被標記爲__block類型時,Clang會對變量進行改寫成一個如下格式的struct:

struct _block_byref_foo {
    void *isa;   // 設置爲NULL
    struct Block_byref *forwarding;   // Block外部變量的地址
    int flags;   //refcount;
    int size;  // size of _block_byref_foo
    typeof(marked_variable) marked_variable;  // copy of Block 外部變量
};

比如:

int __block i = 10;
i = 11;

會被Clang改寫做:

struct _block_byref_i {
  void *isa;
  struct _block_byref_i *forwarding;
  int flags;   //refcount;
  int size;
  int captured_i;
} i = { NULL, &i, 0, sizeof(struct _block_byref_i), 10 };

i.forwarding->captured_i = 11;

可以看到,int __block i 被改寫爲了struct _block_byref_i 結構體。

這裏有個關鍵的屬性變量,forwardingforwarding指向一個__block結構體。
當Block在棧上時,forwarding會指向Block自身的棧地址。而當Block在堆上生成一份copy時,這時候棧上的forwarding會指向堆上的那一份拷貝。

也就是說,只要通過forwarding來操作__block結構體捕獲的外部變量,實質上是操作的同一個變量。
我們用圖片可以更清楚的弄懂其中的原理:
在這裏插入圖片描述

這也就是爲什麼,即使Block外和Block內部i分別是兩個變量,而i的值卻可以被改變的原因。因爲在__block結構體中,通過forwarding指針指向了外部的i的地址,當在Block內部改變i的值時,只需要通過forwarding指針直接修改地址中的值即可。

當我們將__block的變量導入Block中時,Clang會作如下改寫:
例如,

int __block i = 2;
functioncall(^{ i = 10; });

會被Clang做如下改寫:

struct _block_byref_i {
    void *isa;  // set to NULL
    struct _block_byref_voidBlock *forwarding;
    int flags;   //refcount;
    int size;
    void (*byref_keep)(struct _block_byref_i *dst, struct _block_byref_i *src);
    void (*byref_dispose)(struct _block_byref_i *);
    int captured_i;
};


struct __block_literal_5 {
    void *isa;
    int flags;
    int reserved;
    void (*invoke)(struct __block_literal_5 *);
    struct __block_descriptor_5 *descriptor;
    struct _block_byref_i *i_holder;
};

void __block_invoke_5(struct __block_literal_5 *_block) {
   _block->forwarding->captured_i = 10;
}

void __block_copy_5(struct __block_literal_5 *dst, struct __block_literal_5 *src) {
     //_Block_byref_assign_copy(&dst->captured_i, src->captured_i);
     _Block_object_assign(&dst->captured_i, src->captured_i, BLOCK_FIELD_IS_BYREF | BLOCK_BYREF_CALLER);
}

void __block_dispose_5(struct __block_literal_5 *src) {
     //_Block_byref_release(src->captured_i);
     _Block_object_dispose(src->captured_i, BLOCK_FIELD_IS_BYREF | BLOCK_BYREF_CALLER);
}

static struct __block_descriptor_5 {
    unsigned long int reserved;
    unsigned long int Block_size;
    void (*copy_helper)(struct __block_literal_5 *dst, struct __block_literal_5 *src);
    void (*dispose_helper)(struct __block_literal_5 *);
} __block_descriptor_5 = { 0, sizeof(struct __block_literal_5) __block_copy_5, __block_dispose_5 };

上面的數據結構會做如下初始化

struct _block_byref_i i = {( .isa=NULL, .forwarding=&i, .flags=0, .size=sizeof(struct _block_byref_i), .captured_i=2 )};
struct __block_literal_5 _block_literal = {
      &_NSConcreteStackBlock,
      (1<<25)|(1<<29), <uninitialized>,
      __block_invoke_5,
      &__block_descriptor_5,
      &i,
};

小測試

題目一. 下面代碼會輸出什麼?

typedef void(^blockType)(void);
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int i = 13;
        blockType blk = ^{
            NSLog(@"In block i = %d", i);
        };
        i += 2;
        
        blk();
        NSLog(@"Now i = %d", i);
        
    }
    return 0;
}

這裏考察對於auto類型變量,Block的截取方式。因爲auto變量會在Block中做一份const copy,因此在Block內外,實質上應該存在兩個i
這裏的輸出爲:
在這裏插入圖片描述

題目二. 下面的代碼會 正常輸出/編譯錯誤/runtime crash

typedef void(^blockType)(void);
int main(int argc, const char * argv[]) {
    @autoreleasepool {
       NSString *str = @"Hello";
        blockType blk = ^{
            str = @"World";
        };
        blk();
        NSLog(@"Now str is %@", str);
    }
    return 0;
}

因爲對於NSObject類型,在Block中會當做NSObject *const obj處理,此時是一個指針常量。對於指針常量,是不能夠更改其指針所指向的位置的,因此,這裏會出現編譯錯誤。

題目三. 下面的代碼會 正常輸出/編譯錯誤/runtime crash

typedef void(^blockType)(void);
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        __block NSString *str = @"Hello";
        blockType blk = ^{
            str = @"World";
        };
        blk();
        NSLog(@"Now str is %@", str);
    }
    return 0;
}

因爲str變量用了__block修飾,因此__block NSString *str 實質上一個__block struct 類型變量:

struct _block_byref_str {
		void *isa;
		struct _block_byref_str  *forwarding;
		int flags;
		int size;
		NSString *captureStr;
}

當創建__block 類型變量時,在Block結構體中,會存儲__block結構體指針:

struct __block_literal {
		void *isa;
		int flags;
		int reserved;
		void (*invoke)(struct __block_literal * _cself);
    	struct __block_descriptor *descriptor;
   		 struct _block_byref_str *str_holder;    // __block結構體指針
}

當調用invoke方法時,會是這樣的:

void invoke(struct __block_literal * _cself) {
		_block_byref_str *str_holder = _cself->str_holder;
		str_holder->forwarding->captureStr = @"World";
}

由於通過forwarding指針,確保了Block外部和內部的str都是一個指針,因此,當Block內部的str指向新的地址時(str = @"World"),在Block外部的str也指向了新的地址。(因爲它們是同一個東西)。
這個過程用圖表示爲:

  1. __block str = @“World”;
    在這裏插入圖片描述

  2. 當在Block中操作str=@"World"時,相應的__block結構體會拷貝到heap上,同時,stack上的__block結構體的forwarding指針也會指向heap上的那份copy:
    在這裏插入圖片描述

  3. 因此,在Block外面再次輸出str的內容時,由於這時候stack上__block結構體的forwarding指針已經指向了heap上的__block結構體,因此也會輸出heap上的captured_str指針所指向的內容:@“World”

爲了驗證我們的猜測,我們可以用如下代碼:
在這裏插入圖片描述

在進入Block前,Block中,進入Block後分別設置斷點,並打印aR指針的地址&aR,會得到如下結果:
在這裏插入圖片描述

可以看到,在Block中和進入Block後,aR的地址是一樣的,而在進入Block之前,則是另一個地址。這是因爲在stack上的__block結構變量,將其forwarding指針指向了heap地址所導致的。

題目四. 下面的代碼會 正常輸出/編譯錯誤/runtime crash

typedef void(^blockType)(void);
int main(int argc, const char * argv[]) {
    @autoreleasepool {
    	NSString *aStr = @"Hello";
        __block NSString *str = aStr;
        blockType blk = ^{
            str = @"World";
        };
        blk();
        NSLog(@"Now a aStr is %@", aStr);
        NSLog(@"Now str is %@", str);
    }
    return 0;
}

這個題目和題目三類似,只不過對於str的賦值由__block NSString *str = @"Hello"變成了__block NSString *str = aStr

上面這段代碼會正常輸出,其結果爲:
在這裏插入圖片描述

至於str爲什麼會由@"Hello"變成@“World”,其原因見題目三。

這裏aStr是沒有任何變化的,這是因爲在將str在Block中賦值爲@"World"時,僅僅是將str指向了新的地址,而沒有更改原地址的內容。而aStr一直指向舊的地址,也就是值爲@"World"的地址。

題目五. 下面的代碼會 正常輸出/編譯錯誤/runtime crash

		NSMutableString *str = [NSMutableString stringWithString:@"Hello"];
        blockType blk = ^{
            [str appendString:@" World"];
        };
        blk();
        NSLog(@"Now str is %@", str);

答案是會正常輸出。因爲對於NSObject類型來說,Block會copy一份指針常量來保存NSObject的地址。所謂指針常量,是指指針指向的地址是不可用更改的。而這裏在Block中,並沒有更改指針指向的地址,而僅僅是改變了指針指向地址中的值,這個操作是允許的。
其輸出結果爲:
在這裏插入圖片描述

同樣的,類似還有下面代碼,也是可以正常運行,並輸出名字Tim:

		MyRetaion *aR = [MyRetaion new];
        aR.name = @"Jack";
        blockType blk = ^{
            aR.name = @"Tim";
        };
        blk();
        NSLog(@"Now name is %@", aR.name);

總結

在本篇文章中,我們根據Clang的官方文檔,分析總結了Clang爲了支持Block,其背後所使用的數據結構。同時,我們重點分析了Block對於不同類型的外部變量的截取方式。按照Block不同的處理方式,Block截取的變量類型可以分爲:

  • auto類型
  • Block類型
  • NSObject類型
  • __block類型

不同的類型,Block都有不同的截取處理方式。

通過深入瞭解Block的機制,相信對大家編程中正確高效的使用Block,是很有幫助的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章