delphi的組件讀寫機制

Delphi的組件讀寫機制(一)

一、流式對象(Stream)和讀寫對象(Filer)的介紹
 在面向對象程序設計中,對象式數據管理佔有很重要的地位。在Delphi中,對對象式數據管理的支持方式是其一大特色。 
 Delphi是一個面向對象的可視化設計與面向對象的語言相結合的集成開發環境。Delphi的核心是組件。組件是對象的一種。Delphi應用程序完全是由組件來構造的,因此開發高性能的Delphi應用程序必然會涉及對象式數據管理技術。

 對象式數據管理包括兩方面的內容:
● 用對象來管理數據
● 對各類數據對象(包括對象和組件)的管理

 Delphi將對象式數據管理類歸結爲Stream對象(Stream)和Filer對象(Filer),並將它們應用於可視組件類庫(VCL)的方方面面。它們提供了豐富的在內存、外存和Windows資源中管理對象的功能,
 Stream對象,又稱流式對象,是TStream、THandleStream、TFileStream、TMemoryStream、TResourceStream和TBlobStream等的統稱。它們分別代表了在各種媒介上存儲數據的能力,它們將各種數據類型(包括對象和組件) 在內存、外存和數據庫字段中的管理操作抽象爲對象方法,並且充分利用了面向對象技術的優點,應用程序可以相當容易地在各種Stream對象中拷貝數據。
 讀寫對象(Filer)包括TFiler對象、TReader對象和TWriter對象。TFiler對象是文件讀寫的基礎對象,在應用程序中使用的主要是TReader和TWriter。TReader和TWriter對象都直接從TFiler對象繼承。TFiler對象定義了Filer對象的基本屬性和方法。
  Filer對象主要完成兩大功能:
● 存取窗體文件和窗體文件中的組件
● 提供數據緩衝,加快數據讀寫操作

 爲了對流式對象和讀寫對象有一個感性的認識,先來看一個例子。
a)寫文件
procedure TFomr1.WriteData (Sender: TObject); r;
Var
  FileStream:TFilestream;
  Mywriter:TWriter;
  i: integer
Begin
  FileStream:=TFilestream.create(‘c:\Test.txt’,fmopenwrite);//創建文件流對象
  Mywriter:=TWriter.create(FileStream,1024); //把Mywriter和FileStream聯繫起來
  Mywriter. writelistbegin;  //寫入列表開始標誌
  For i:=0 to Memo1.lines.count-1 do   
    Mywriter.writestring(memo1.lines[i]); //保存Memo組件中文本信息到文件中
  Mywriter.writelistend;          //寫入列表結束標誌
  FileStream.seek(0,sofrombeginning); //文件流對象指針移到流起始位置
  Mywriter.free; //釋放Mywriter對象
  FileStream.free; //釋放FileStream對象
End;
 
b)讀文件
procedure TForm1.ReadData(Sender: TObject);
Var
  FileStream:TFilestream;
  Myreader:TReader;
Begin
  FileStream:=TFilestream.create(‘c:\Test.txt’,fmopenread);
  Myreader:=TRreader.create(FileStream,1024); //把Myreader和FileStream聯繫起來
  Myreader.readlistbegin;  //把寫入的列表開始標誌讀出來
  Memo1.lines.clear;  //清除Memo1組件的文本內容
  While not myreader.endoflist do //注意TReader的一個方法:endoflist
  Begin
    Memo1.lines.add(myreader.readstring); //把讀出的字符串加到Memo1組件中
  End;
  Myreader.readlistend; //把寫入的列表結束標誌讀出來
  Myreader.free;  //釋放Myreader對象
  FileStream.free; //釋放FileStream對象
End;
 上面兩個過程,一個爲寫過程,另一個爲讀過程。寫過程通過TWriter,利用TFilestream把一個Memo中的內容(文本信息)存爲一個保存在磁盤上的二進制文件。讀過程剛好和寫過程相反,通過TReader,利用TFilestream把二進制文件中的內容轉換爲文本信息並顯示在Memo中。運行程序可以看到,讀過程忠實的把寫過程所保存的信息進行了還原。
 下圖描述了數據對象(包括對象和組件)、流式對象和讀寫對象之間的關係。
 
         圖(一)

 值得注意的是,讀寫對象如TFiler對象、TReader對象和TWriter對象等很少由應用程序編寫者進行直接的調用,它通常用來讀寫組件的狀態,它在讀寫組件機制中扮演着非常重要的角色。
對於流式對象Stream,很多參考資料上都有很詳細的介紹,而TFiler對象、TReader對象和TWriter對象特別是組件讀寫機制的參考資料則很少見,本文將通過對VCL原代碼的跟蹤而對組件讀寫機制進行剖析。

二、讀寫對象(Filer)與組件讀寫機制
 Filer對象主要用於存取Delphi的窗體文件和窗體文件中的組件,所以要清楚地理解Filer對象就要清楚Delphi 窗體文件(DFM文件)的結構。
  DFM文件是用於Delphi存儲窗體的。窗體是Delphi可視化程序設計的核心。窗體對應Delphi應用程序中的窗口,窗體中的可視組件對應窗口中的界面元素,非可視組件如TTimer和TOpenDialog,對應Delphi應用程序的某項功能。Delphi應用程序的設計實際上是以窗體的設計爲中心。因此,DFM文件在Delphi應用設計中也佔很重要的位置。窗體中的所有元素包括窗體自身的屬性都包含在DFM文件中。
  在Delphi應用程序窗口中,界面元素是按擁有關係相互聯繫的,因此樹狀結構是最自然的表達形式;相應地,窗體中的組件也是按樹狀結構組織;對應在DFM文件中,也要表達這種關係。DFM文件在物理上,是以文本方式存儲的(在Delphi2.0版本以前是存儲爲二進制文件的),在邏輯上則是以樹狀結構安排各組件的關係。從該文本中可以看清窗體的樹狀結構。下面是DFM文件的內容:
object Form1: TForm1
  Left = 1Array7
  Top = 124
  ……
  PixelsPerInch = Array6
  TextHeight = 13
  object Button1: TButton
    Left = 272
    ……
    Caption = Button1
    TabOrder = 0
  end
  object Panel1: TPanel
    Left = 120
    ……
    Caption = Panel1
    TabOrder = 1
    object CheckBox1: TCheckBox
      Left = 104
      ……
   Caption = CheckBox1
      TabOrder = 0
    end
  end
end
 這個DFM文件就是TWriter通過流式對象Stream來生成的,當然這裏還有一個二進制文件到文本信息文件的轉換過程,這個轉換過程不是本文要研究的對象,所以忽略這樣的一個過程。
 在程序開始運行的時候,TReader通過流式對象Stream來讀取窗體及組件,因爲Delphi在編譯程序的時候,利用編譯指令{$R *.dfm}已經把DFM文件信息編譯到可執行文件中,因此TReader讀取的內容實際上是被編譯到可執行文件中的有關窗體和組件的信息。
 TReader和TWriter不僅能夠讀取和寫入Object Pascal中絕大部分標準數據類型,而且能夠讀寫List、Variant等高級類型,甚至能夠讀寫Perperties和Component。不過,TReader、TWriter自身實際上提供的功能很有限,大部分實際的工作是由TStream這個非常強大的類來完成的。也就是說TReader、TWriter實際上只是一個工具,它只是負責怎麼去讀寫組件,至於具體的讀寫操作是由TStream來完成的。
 由於TFiler是TReader和TWriter的公共祖先類,因爲要了解TReader和TWriter,還是先從TFiler開始。


TFiler

       先來看一下TFiler類的定義:

  TFiler = class(TObject)

  private

    FStream: TStream;

    FBuffer: Pointer;

    FBufSize: Integer;

    FBufPos: Integer;

    FBufEnd: Integer;

    FRoot: TComponent;

    FLookupRoot: TComponent;

    FAncestor: TPersistent;

    FIgnoreChildren: Boolean;

  protected

    procedure SetRoot(Value: TComponent); virtual;

  public

    constructor Create(Stream: TStream; BufSize: Integer);

    destructor Destroy; override;

    procedure DefineProperty(const Name: string;

      ReadData: TReaderProc; WriteData: TWriterProc;

      HasData: Boolean); virtual; abstract;

    procedure DefineBinaryProperty(const Name: string;

      ReadData, WriteData: TStreamProc;

      HasData: Boolean); virtual; abstract;

    procedure FlushBuffer; virtual; abstract;

    property Root: TComponent read FRoot write SetRoot;

    property LookupRoot: TComponent read FLookupRoot;

    property Ancestor: TPersistent read FAncestor write FAncestor;

    property IgnoreChildren: Boolean read FIgnoreChildren write FIgnoreChildren;

  end;

       TFiler對象是TReader和TWriter的抽象類,定義了用於組件存儲的基本屬性和方法。它定義了Root屬性,Root指明瞭所讀或寫的組件的根對象,它的Create方法將Stream對象作爲傳入參數以建立與Stream對象的聯繫, Filer對象的具體讀寫操作都是由Stream對象完成。因此,只要是Stream對象所能訪問的媒介都能由Filer對象存取組件。

       TFiler 對象還提供了兩個定義屬性的public方法:DefineProperty和DefineBinaryProperty,這兩個方法使對象能讀寫不在組件published部分定義的屬性。下面重點介紹一下這兩個方法。

       Defineproperty ( )方法用於使標準數據類型持久化,諸如字符串、整數、布爾、字符、浮點和枚舉。

       在Defineproperty方法中。Name參數用於指定應寫入DFM文件的屬性的名稱,該屬性不在類的published部分定義。

       ReadData和WriteData參數指定在存取對象時讀和寫所需數據的方法。ReadData參數和WriteData參數的類型分別是TReaderProc和TWriterProc。這兩個類型是這樣聲明的:

  TReaderProc = procedure(Reader: TReader) of object;

  TWriterProc = procedure(Writer: TWriter) of object;

       HasData參數在運行時決定了屬性是否有數據要存儲。

       DefineBinaryProperty方法和Defineproperty有很多的相同之處,它用來存儲二進制數據,如聲音和圖象等。

       下面來說明一下這兩個方法的用途。

       我們在窗體上放一個非可視化組件如TTimer,重新打開窗體時我們發現TTimer還是在原來的地方,但TTimer沒有Left和Top屬性啊,那麼它的位置信息保存在哪裏呢?

       打開該窗體的DFM文件,可以看到有類似如下的幾行內容:

  object Timer1: TTimer

    Left = 184

    Top = 14Array

  end

Delphi的流系統只能保存published數據,但TTimer並沒有published的Left和Top屬性,那麼這些數據是怎麼被保存下來的呢?

TTimer是TComponent的派生類,在TComponent類中我們發現有這樣的一個函數:

procedure TComponent.DefineProperties(Filer: TFiler);

var

  Ancestor: TComponent;

  Info: Longint;

begin

  Info := 0;

  Ancestor := TComponent(Filer.Ancestor);

  if Ancestor <> nil then Info := Ancestor.FDesignInfo;

  Filer.DefineProperty(Left, ReadLeft, WriteLeft,

    LongRec(FDesignInfo).Lo <> LongRec(Info).Lo);

  Filer.DefineProperty(Top, ReadTop, WriteTop,

    LongRec(FDesignInfo).Hi <> LongRec(Info).Hi);

end;

       TComponent的DefineProperties是覆蓋了它的祖先類TPersistent的方法,在TPersistent類中該方法爲空的虛方法。

       在DefineProperties方法中,我們可以看出,有一個Filer對象作爲它的參數,當定義屬性時,它引用了Ancestor屬性,如果該屬性非空,對象應當只讀寫與從Ancestor繼承的不同的屬性的值。它調用TFiler的DefineProperty方法,並定義了ReadLeft,WriteLeft,ReadTop,WriteTop方法來讀寫Left和Top屬性。

       因此,凡是從TComponent派生的組件,即使它沒有Left和Top屬性,在流化到DFM文件中,都會存在這樣的兩個屬性。

 

       在查找資料的過程中,發現很少有資料涉及到組件讀寫機制的。由於組件的寫過程是在設計階段由Delphi的IDE來完成的,因此無法跟蹤它的運行過程。所以筆者是通過在程序運行過程中跟蹤VCL原代碼來了解組件的讀機制的,又通過讀機制和TWriter來分析組件的寫機制。所以下文將按照這一思維過程來講述組件讀寫機制,先講TReader,而後是TWriter。


TReader

       先來看Delphi的工程文件,會發現類似這樣的幾行代碼:

begin

  Application.Initialize;

  Application.CreateForm(TForm1, Form1);

  Application.Run;

end.

       這是Delphi程序的入口。簡單的說一下這幾行代碼的意義:Application.Initialize對開始運行的應用程序進行一些必要的初始化工作,Application.CreateForm(TForm1, Form1)創建必要的窗體,Application.Run程序開始運行,進入消息循環。

       現在我們最關心的是創建窗體這一句。窗體以及窗體上的組件是怎麼創建出來的呢?在前面已經提到過:窗體中的所有組件包括窗體自身的屬性都包含在DFM文件中,而Delphi在編譯程序的時候,利用編譯指令{$R *.dfm}已經把DFM文件信息編譯到可執行文件中。因此,可以斷定創建窗體的時候需要去讀取DFM信息,用什麼去讀呢,當然是TReader了!

       通過對程序的一步步的跟蹤,可以發現程序在創建窗體的過程中調用了TReader的ReadRootComponent方法。該方法的作用是讀出根組件及其所擁有的全部組件。來看一下該方法的實現:

 

function TReader.ReadRootComponent(Root: TComponent): TComponent;

……

begin

  ReadSignature;

  Result := nil;

  GlobalNameSpace.BeginWrite;  // Loading from stream adds to name space

  try

    try

      ReadPrefix(Flags, I);

      if Root = nil then

      begin

        Result := TComponentClass(FindClass(ReadStr)).Create(nil);

        Result.Name := ReadStr;

      end else

      begin

        Result := Root;

        ReadStr; { Ignore class name }

        if csDesigning in Result.ComponentState then

          ReadStr else

        begin

          Include(Result.FComponentState, csLoading);

          Include(Result.FComponentState, csReading);

          Result.Name := FindUniqueName(ReadStr);

        end;

      end;

      FRoot := Result;

      FFinder := TClassFinder.Create(TPersistentClass(Result.ClassType), True);

      try

        FLookupRoot := Result;

        G := GlobalLoaded;

        if G <> nil then

          FLoaded := G else

          FLoaded := TList.Create;

        try

          if FLoaded.IndexOf(FRoot) < 0 then

            FLoaded.Add(FRoot);

          FOwner := FRoot;

          Include(FRoot.FComponentState, csLoading);

          Include(FRoot.FComponentState, csReading);

          FRoot.ReadState(Self);

          Exclude(FRoot.FComponentState, csReading);

          if G = nil then

            for I := 0 to FLoaded.Count - 1 do TComponent(FLoaded[I]).Loaded;

        finally

          if G = nil then FLoaded.Free;

          FLoaded := nil;

        end;

      finally

        FFinder.Free;

      end;

     ……

  finally

    GlobalNameSpace.EndWrite;

  end;

end;

       ReadRootComponent首先調用ReadSignature讀取Filer對象標籤(’TPF0’)。載入對象之前檢測標籤,能防止疏忽大意,導致讀取無效或過時的數據。

       再看一下ReadPrefix(Flags, I)這一句,ReadPrefix方法的功能與ReadSignature的很相象,只不過它是讀取流中組件前面的標誌(PreFix)。當一個Write對象將組件寫入流中時,它在組件前面預寫了兩個值,第一個值是指明組件是否是從祖先窗體中繼承的窗體和它在窗體中的位置是否重要的標誌;第二個值指明它在祖先窗體創建次序。

       然後,如果Root參數爲nil,則用ReadStr讀出的類名創建新組件,並從流中讀出組件的Name屬性;否則,忽略類名,並判斷Name屬性的唯一性。

          FRoot.ReadState(Self);

       這是很關鍵的一句,ReadState方法讀取根組件的屬性和其擁有的組件。這個ReadState方法雖然是TComponent的方法,但進一步的跟蹤就可以發現,它實際上最終還是定位到了TReader的ReadDataInner方法,該方法的實現如下:

procedure TReader.ReadDataInner(Instance: TComponent);

var

  OldParent, OldOwner: TComponent;

begin

  while not EndOfList do ReadProperty(Instance);

  ReadListEnd;

  OldParent := Parent;

  OldOwner := Owner;

  Parent := Instance.GetChildParent;

  try

    Owner := Instance.GetChildOwner;

    if not Assigned(Owner) then Owner := Root;

    while not EndOfList do ReadComponent(nil);

    ReadListEnd;

  finally

    Parent := OldParent;

    Owner := OldOwner;

  end;

end;

       其中有這樣的這一行代碼:

  while not EndOfList do ReadProperty(Instance);

       這是用來讀取根組件的屬性的,對於屬性,前面提到過,既有組件本身的published屬性,也有非published屬性,例如TTimer的Left和Top。對於這兩種不同的屬性,應該有兩種不同的讀方法,爲了驗證這個想法,我們來看一下ReadProperty方法的實現。

procedure TReader.ReadProperty(AInstance: TPersistent);

……

begin

       ……

      PropInfo := GetPropInfo(Instance.ClassInfo, FPropName);

      if PropInfo <> nil then ReadPropValue(Instance, PropInfo) else

      begin

        { Cannot reliably recover from an error in a defined property }

        FCanHandleExcepts := False;

        Instance.DefineProperties(Self);

        FCanHandleExcepts := True;

        if FPropName <> then

          PropertyError(FPropName);

      end;

       ……

end;

       爲了節省篇幅,省略了一些代碼,這裏說明一下:FPropName是從文件讀取到的屬性名。

      PropInfo := GetPropInfo(Instance.ClassInfo, FPropName);

       這一句代碼是獲得published屬性FPropName的信息。從接下來的代碼中可以看到,如果屬性信息不爲空,就通過ReadPropValue方法讀取屬性值,而ReadPropValue方法是通過RTTI函數來讀取屬性值的,這裏不再詳細介紹。如果屬性信息爲空,說明屬性FPropName爲非published的,它就必須通過另外一種機制去讀取。這就是前面提到的DefineProperties方法,如下:

       Instance.DefineProperties(Self);

       該方法實際上調用的是TReader的DefineProperty方法:

procedure TReader.DefineProperty(const Name: string;

  ReadData: TReaderProc; WriteData: TWriterProc; HasData: Boolean);

begin

  if SameText(Name, FPropName) and Assigned(ReadData) then

  begin

    ReadData(Self);

    FPropName := ;

  end;

end;

       它先去比較讀取的屬性名是否和預設的屬性名相同,如果相同並且讀方法ReadData不爲空時就調用ReadData方法讀取屬性值。

       好了,根組件已經讀上來了,接下來應該是讀該根組件所擁有的組件了。再來看方法:

procedure TReader.ReadDataInner(Instance: TComponent);

       該方法後面有一句這樣的代碼:

    while not EndOfList do ReadComponent(nil);

       這正是用來讀取子組件的。子組件的讀取機制是和上面所介紹的根組件的讀取一樣的,這是一個樹的深度遍歷。

       到這裏爲止,組件的讀機制已經介紹完了。

 

       再來看組件的寫機制。當我們在窗體上添加一個組件時,它的相關的屬性就會保存在DFM文件中,這個過程就是由TWriter來完成的。

 

Ø        TWriter

       TWriter 對象是可實例化的往流中寫數據的Filer對象。TWriter對象直接從TFiler繼承而來,除了覆蓋從TFiler繼承的方法外,還增加了大量的關於寫各種數據類型(如Integer、String和Component等)的方法。

       TWriter對象提供了許多往流中寫各種類型數據的方法, TWrite對象往流中寫數據是依據不同的數據採取不同的格式的。 因此要掌握TWriter對象的實現和應用方法,必須瞭解Writer對象存儲數據的格式。

  首先要說明的是,每個Filer對象的流中都包含有Filer對象標籤。該標籤佔四個字節其值爲“TPF0”。Filer對象爲WriteSignature和ReadSignature方法存取該標籤。該標籤主要用於Reader對象讀數據(組件等)時,指導讀操作。

  其次,Writer對象在存儲數據前都要留一個字節的標誌位,以指出後面存放的是什麼類型的數據。該字節爲TValueType類型的值。TValueType是枚舉類型,佔一個字節空間,其定義如下:

 

  TValueType = (VaNull, VaList, VaInt8, VaInt16, VaInt32, VaEntended, VaString, VaIdent,

VaFalse, VaTrue, VaBinary, VaSet, VaLString, VaNil, VaCollection);

 

       因此,對Writer對象的每一個寫數據方法,在實現上,都要先寫標誌位再寫相應的數據;而Reader對象的每一個讀數據方法都要先讀標誌位進行判斷,如果符合就讀數據,否則產生一個讀數據無效的異常事件。VaList標誌有着特殊的用途,它是用來標識後面將有一連串類型相同的項目,而標識連續項目結束的標誌是VaNull。因此,在Writer對象寫連續若干個相同項目時,先用WriteListBegin寫入VaList標誌,寫完數據項目後,再寫出VaNull標誌;而讀這些數據時,以ReadListBegin開始,ReadListEnd結束,中間用EndofList函數判斷是否有VaNull標誌。

       來看一下TWriter的一個非常重要的方法WriteData:

procedure TWriter.WriteData(Instance: TComponent);

……

begin

  ……

  WritePrefix(Flags, FChildPos);

  if UseQualifiedNames then

    WriteStr(GetTypeData(PTypeInfo(Instance.ClassType.ClassInfo)).UnitName + . + Instance.ClassName)

  else

    WriteStr(Instance.ClassName);

  WriteStr(Instance.Name);

  PropertiesPosition := Position;

  if (FAncestorList <> nil) and (FAncestorPos < FAncestorList.Count) then

  begin

    if Ancestor <> nil then Inc(FAncestorPos);

    Inc(FChildPos);

  end;

  WriteProperties(Instance);

  WriteListEnd;

  ……

end;

       從WriteData方法中我們可以看出生成DFM文件信息的概貌。先寫入組件前面的標誌(PreFix),然後寫入類名、實例名。緊接着有這樣的一條語句:

  WriteProperties(Instance);

       這是用來寫組件的屬性的。前面提到過,在DFM文件中,既有published屬性,又有非published屬性,這兩種屬性的寫入方法應該是不一樣的。來看WriteProperties的實現:

procedure TWriter.WriteProperties(Instance: TPersistent);

……

begin

  Count := GetTypeData(Instance.ClassInfo)^.PropCount;

  if Count > 0 then

  begin

    GetMem(PropList, Count * SizeOf(Pointer));

    try

      GetPropInfos(Instance.ClassInfo, PropList);

      for I := 0 to Count - 1 do

      begin

        PropInfo := PropList^[I];

        if PropInfo = nil then

          Break;

        if IsStoredProp(Instance, PropInfo) then

          WriteProperty(Instance, PropInfo);

      end;

    finally

      FreeMem(PropList, Count * SizeOf(Pointer));

    end;

  end;

  Instance.DefineProperties(Self);

end;

       請看下面的代碼:

        if IsStoredProp(Instance, PropInfo) then

          WriteProperty(Instance, PropInfo);

       函數IsStoredProp通過存儲限定符來判斷該屬性是否需要保存,如需保存,就調用WriteProperty來保存屬性,而WriteProperty是通過一系列的RTTI函數來實現的。

       Published屬性保存完後就要保存非published屬性了,這是通過這句代碼完成的:

  Instance.DefineProperties(Self);

       DefineProperties的實現前面已經講過了,TTimer的Left、Top屬性就是通過它來保存的。

       好,到目前爲止還存在這樣的一個疑問:根組件所擁有的子組件是怎麼保存的?再來看WriteData方法(該方法在前面提到過):

procedure TWriter.WriteData(Instance: TComponent);

……

begin

  ……

    if not IgnoreChildren then

      try

        if (FAncestor <> nil) and (FAncestor is TComponent) then

        begin

          if (FAncestor is TComponent) and (csInline in TComponent(FAncestor).ComponentState) then

            FRootAncestor := TComponent(FAncestor);

          FAncestorList := TList.Create;

          TComponent(FAncestor).GetChildren(AddAncestor, FRootAncestor);

        end;

        if csInline in Instance.ComponentState then

          FRoot := Instance;

        Instance.GetChildren(WriteComponent, FRoot);

      finally

        FAncestorList.Free;

      end;

end;

       IgnoreChildren屬性使一個Writer對象存儲組件時可以不存儲該組件擁有的子組件。如果IgnoreChildren屬性爲True,則Writer對象存儲組件時不存它擁有的子組件。否則就要存儲子組件。

        Instance.GetChildren(WriteComponent, FRoot);

       這是寫子組件的最關鍵的一句,它把WriteComponent方法作爲回調函數,按照深度優先遍歷樹的原則,如果根組件FRoot存在子組件,則用WriteComponent來保存它的子組件。這樣我們在DFM文件中看到的是樹狀的組件結構。

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