android虛擬機原理---運行流程概述

前言

Android中,系統通過init進程創建出來的zygote進程,爲每個應用創建一個進程和複製一個虛擬機實例,而每個應用都運行在一個DVM或AVM實例中;而且每個進程或線程都對應linux中的一個進程或線程

一JVM、DalvikVM、ArtVM區別

Jvm(java虛擬機):標準的虛擬機,java文件編譯生成標準的多個java字節碼(.class)文件,並打包成ja文件,而 jvm運行的字節碼就是從jar和.class文件中獲取的;此外jvm是基於棧的,基於棧的機器必須使用指令來載入

DVM:基於寄存器,需要更大的指令;將java編譯成.class文件,然後通過dx工具將多個.class優化成一個.dex文件(classes.dex)打包進入apk中,.dex文件時專爲app生成的壓縮格式;app安裝時會有opt工具優化成odex文件,每次運行時都會解釋odex生成本機機器碼

AVM:和DVM類似,不同的是AVM在安裝apk時會將一個或多個dex優化成oat本地機器碼文件,運行時不再重新解釋,比DVM更快

二VM的沙箱模型

Vm的沙箱模型其實指的就是vm的安全性。沙箱限制一切可能對系統造成破壞的動作指令

 安全性:指的是對一些非本地代碼(網絡下載的、來源不清楚的等)不可靠代碼限制了權限;主要包括
   1、對本地硬盤的操作

   2、進行任何網絡連接,但不能連接到提供者

   3、創建新的進程

   4、裝載新的動態鏈接庫

沙箱模型中的4個組件:

 1、類加載體系結構

 2、Class文件校驗器

   3、內置於java虛擬機(及語言)的特性

   4、安全管理器及javaAPI

前3個的安全特性保持JVM的實例和它正在運行的應用程序的內部完整性,防止惡意代碼的侵犯;而第4個安全管理器使得外部資源不受jvm內部惡意代碼的破壞,也就是這個安全管理器是運行在jvm中的外部資源權限管理器

三.內存工作模型


Java的多線程併發問題最終都反應在java內存模型上(上圖),而所謂的併發問題也就是多線程的安全性;而線程的安全性無非就是要控制多個線程對某個資源的有序訪問或修改。所以我們要解決的是:資源的有序性和可見性

Jvm的內存模型中,定義了線程對主內存的操作指令:read、load、use(使用變量)、assign(賦值)、store、write、lock、unlock

Lock和unlock 作用於主內存的變量,即變量被某個線程鎖定和解鎖

Read作用於工作內存的變量,read將主內存的變量值傳入到工作內存

load:將從主存獲取變量值放入工作內存的變量副本中

Use:使用工作內存的變量,傳遞值給執行引擎

Store:工作內存中變量的值傳送到主內存中

Write:把store傳入的值放入到主內存的變量中(更新)

 

可見性:多個線程之間是不能互相傳遞數據通信的,他們之間只能通過共享變量來進行。

        共享變量指的就是實例字段、靜態字段、和構成數組對象的元素,但不包括局部變量與方法參數,因爲這是後者私有的,不會被共享就不會存在競爭安全問題

當一個共享變量在多個線程的工作內存中都有副本時,任何一個線程副本修改了這個共享變量,其它線程都能知道共享變量被修改了;這就是可見性問題

有序性:在線程引用一個變量時不能直接從主內存中引用,若線程的工作內存中沒有該變量,則從主內存中複製一個副本到工作內存中(read-load);同一線程再次引用這個字段時,可能重新從主內存中讀取也可能繼續使用之前的副本也就是說read、load、use順序是由JVM決定的;

原子性:像數據庫的事務一樣,要麼成功,要是失敗

所以主內存是多個線程共享的,當新建一個對象時也是被分配到主內存中的,每個線程都有一個工作內存,工作內存存儲了主內存的對象的副本;

線程操作某個對象時執行順序如下:

  1、  從主內存箇中複製變量到當前工作內存(執行read and load指令)

  2、  執行代碼,改變共享變量值(useand assign):是在工作內存中修改的

  3、  用工作內存數據刷新主內存相關內容(store and write指令)

順序是不能改變的,也就是說同一時刻操作一個變量必須鎖住。保證有序性

四.內存回收機制

Dvm的垃圾收集器運行過程中,除了垃圾收集器整個dvm的程序都要停止運行,應用程序必須(所以線程)暫時停止,知道運行完成,才能重新啓動應用程序運行

先了解幾個概念

寄存器:在程序中無法控制,不用管

java棧:存放基本類型的變量數據和對象的引用,對象本身不再棧而是在堆中,棧在線程創建時創建,即爲每個線程創建對應的棧內存,線程銷燬也會回收棧內存

    有許多棧幀,一個方法對應一個棧幀,保存的是方法的狀態

java堆:存放new產生的數據(對象);是由jvm自動垃圾收集器來管理的;堆會分爲永久區、新生區、養年區;

         永久區:數據不會被回收

        養老區:用於保存從新生區篩選出來的java對象,一般池的對象都活躍在這裏

        新生區:new出來的對象都是保存在這,當此區已滿時,垃圾回收器將不回收的對象存放到老年區

靜態域:存放在對象中用static定義的靜態成員

方法區:常量池:存放常量;包括:類和接口的全限定名、字段名稱和描述、方法和名稱描述、直接常量(常量池中);常量池存儲在方法區中

非RAM存儲:硬盤等永久存儲空間

棧和堆是不同管理的,雖然棧保存的變量的引用地址指向堆內存真實對象,但當棧的變量不在作用域時會回收棧的變量(比如方法中的臨時變量或此方法的線程結束了),但是堆中的對象並不是立馬回收,只是沒有指向這個對象的引用,系統會把它標誌成垃圾對象,當系統觸發回收機制時纔會回收。

我們都是知道回收機制都是從根集合開始遍歷,查找直接或間接的引用的對象打個mark,將引用不到或者沒引用的對象回收,但是怎麼知道這個對象是不是被引用的?

要理解回收機制,就要知道JVM或DVM使用的是什麼算法,算法的原理,誰是根集合。

根集合:所謂的根集合就是基於線程出發的,每個線程都會創建對應棧內存,而棧內存保存;包括寄存器、java類靜態字段、局部和全局的JNI引用、jvm每個方法對應的棧幀中的對象引用(如對象引用的局部變量和參數);所有這些引用構成的就是根集合

   從根集合遞歸就可追蹤可訪問的對象集合

算法:

  1、  引用計數

  2、  MarkSweep算法(標記和清除)

  3、  SemiSpaceCopy

這裏dvm主要用的MarkSweep算法,所以淺談一下。

MarkSweep:分爲Mark階段、Sweep階段和可選的Compact階段(DVM不實現,不用管)

垃圾收集器運行的分爲如下步驟

第一:Mark階段(追蹤標誌階段):

標記出所有活動對象,垃圾收集器通過棧來保存根集合,然後對棧中的每一個元素,遞歸追蹤所有可訪問的對象,並在markBits位圖將對象的內存起始地址的置爲1,當遍歷完所有棧元素,即所有元素都出棧時,markBits就是所有可訪問的對象的集合

第二:Sweep階段(回收階段):

回收內存階段,由於markBits位圖可知所有可訪問的對象(不會回收的對象),而liveBits位圖是所有對象的集合,所以比較兩個位圖就知道哪些對象時需要回收的;而後會調用free來回收內存


五.JNI (結合網上例子來理解更好)

概述:

JNI:java本地接口:是java和其它語言(c/c++)代碼交互的一種接口規範,相當於協議。也作爲java和c/c++交互的中間層

Native:.c或.cpp按照規範實現的c/c++方法,在此方法中可以通過env指針實現調用java方法、屬性、類,也可以調用c/c++原生方法和java通信(傳遞數據、修改屬性等等)

在native方法中,java的基礎類型(float/int/array/class/Object)都有對應的jni的類(比如jfloat/jint/jXXXArray/jclass/jObject等等)也就是java 類中定義的native方法傳的參數是java基礎類型,到了Native方法(cpp、c)實現中參數類型就是jxxx

在native的實現方法中若是想要將jxxx變量在c/c++方法中使用,必須通過env轉成c、c++的正常基礎類型的變量比如(int、float/char/指針等等),同理也是一樣在native方法實現中想將c的變量的值想要回傳給java也是要轉成相應的jxxx返回;

總而言之,native方法中操作java的對應基礎類型是jxxxx,操作c、c++原生的就用原生的類型。這就是JNI中間層的能力

以下抄自小楠總的博客:https://www.jianshu.com/p/919719964ec4

每個native函數,都至少有兩個參數(JNIEnv*,jclass或者jobject)。

當native方法爲靜態方法時:

 1) jclass 代表native方法所屬類的class對象(JniTest.class)。

 2)當native方法爲非靜態方法時:jobject代表native方法所屬的對象。

JNI開發中JNIEnv在C和C++中實現的區別

JNIEnv:JNIEnv裏面有很多方法,與Java進行交互,代表Java的運行環境。JNIEnvironment。

在C中:

JNIEnv 結構體指針的別名

env 二級指針

JNIEXPORT jstringJNICALL Java_com_test_JniTest_getStringFromC

(JNIEnv * env,jclass jcls){

    //env是一個二級指針,函數中需要再次傳入

    return(*env)->NewStringUTF(env, "String From C");

}


在C++中:

JNIEnv 是一個結構體的別名

env 一級指針

JNIEXPORT jstringJNICALL Java_com_test_JniTest_getStringFromC

(JNIEnv * env,jclass jcls){

    //env是一個一級指針,函數中不需要再次傳入

    returnenv->NewStringUTF("StringFrom C");

}


jni.h頭文件中有下面的預編譯代碼:

#ifdef __cplusplus
typedef JNIEnv_ JNIEnv;
#else
typedefconststructJNINativeInterface_ *JNIEnv;
#endif



 

註冊

 靜態註冊

   其實也就是平時開發中遵循JNI的規範 :映射java方法和c/c++中的方法對應

   再來看在cpp中定義的函數名:Java_com_jni_HelloWorld_helloworld

java文件與cpp文件中函數名的配對定義方式爲Java + 包名 +java類名 + 方法/函數名,中間用_分隔;其中兩個參數分別是:

比如:

cpp中定義的函數名:Java_com_jni_HelloWorld_helloworld

javapublic void native helloworld();

jni目錄下添加一個helloworld.cpp

#include<jni.h>

#include<android/log.h>

#include <string.h>

 

#ifndef_Included_com_jni_HelloWorld // 1

#define _Included_com_jni_HelloWorld

 

#ifdef __cplusplus// 2

extern"C" {

#endif// 2

JNIEXPORT jstringJNICALL Java_com_jni_HelloWorld_helloworld(JNIEnv *, jobject);

#ifdef __cplusplus// 3

}

#endif// 3

#endif// 1

 

JNIEXPORT jstringJNICALL Java_com_jni_HelloWorld_helloworld(JNIEnv * env,

        jobject obj) {

    return env->NewStringUTF("helloworld");

}


 

   具體包括方法申明和方法實現兩個部分;這個編譯鏈接之後就可以調用了。

可以看出靜態註冊:是通過c/c++裏面方法的名稱來映射函數

動態註冊                 

可以不用遵循jni註冊函數的規範,直接在.cpp動態註冊;當System.loadlibrary(”so文件”)時,會執行JNI_Onload()方法,在此方法調用

registerNativeMethods(xxx,“包名/類名”,JNINativeMethod[],methodNum),方法傳入JNINativeMethod[]數組實例gmethods[]和這些方法聲明在java中的包名/類名,就會將本地函數登記到VM中,這樣就可以互相通信了,而且登記到vm中可以加快本地函數調用的效率

應用層級的java類穿過VM呼叫(調用)到本地函數,這個過程是通過vm去尋找“*.so”多個so文件的本地函數,要調用多次就會尋找多次,這會花費很多時間,所以可以向VM登記本地函數到JNINativeMethod[],這樣只需要查找這個方法對應表就可以知道調用哪個本地方法了

以下抄自網絡的一個例子:

JNINativeMethod 結構體的官方定義

 typedef struct {    

   const char* name;    

    const char* signature;    

    void* fnPtr;    

  } JNINativeMethod; 

第一個變量nameJava中函數的名字。
第二個變量signature,用字符串是描述了Java中函數的參數和返回值
第三個變量fnPtr是函數指針,指向native函數。前面都要接 (void *)

所以JniClient.c文件裏面有下面的代碼

JniClient.java文件

public class JniClient {
 static {  

          System.loadLibrary("FirstJni");  
     }  
  public  JniClient() {  
  }  
  public native String getStr();  
  public native int addInt(int a, int b);  

}  

3JniClient.c文件(新建一個文件夾jni,然後把這個文件放在jni文件夾裏面)

 #define JNIREG_CLASS "com/example/chenyu/test/JniClient"//指定要註冊的類  
 jstring get_strstr(JNIEnv* env, jobject thiz)  
{  
 return (*env)->NewStringUTF(env, "I am chenyu, 動態註冊JNI");  
}  

 jint add_int(JNIEnv* env, jobject jobj, jint num1, jint num2){  

 return num1 + num2;  

}  

 

/**  

 * 方法對應表  

*/  

static JNINativeMethod gMethods[] = {  
   {"getStr", "()Ljava/lang/String;", (void*)get_str},  

        {"addInt", "(II)I", (void*)add_int},  

 };  

 

/*  

 * 爲某一個類註冊本地方法  

 */  

static int registerNativeMethods(JNIEnv* env  , const char* className  
 , JNINativeMethod* gMethods, int numMethods) {  

 jclass clazz;  

 clazz = (*env)->FindClass(env, className);  

  if (clazz == NULL) {  
      return JNI_FALSE;  
  }  
 if ((*env)->RegisterNatives(env, clazz, gMethods, numMethods) < 0) {  
     return JNI_FALSE;  
  }  
 return JNI_TRUE;  
}  

 /*  
 * 爲所有類註冊本地方法  

*/  

 static int registerNatives(JNIEnv* env) {  
  return registerNativeMethods(env, JNIREG_CLASS, gMethods,  
                sizeof(gMethods) / sizeof(gMethods[0]));  
}  

 /*  
* System.loadLibrary("lib")時調用  

 * 如果成功返回JNI版本, 失敗返回-1  

*/  
 JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {  
 JNIEnv* env = NULL;  
jint result = -1;  
  if ((*vm)->GetEnv(vm, (void**) &env, JNI_VERSION_1_4) != JNI_OK) {  
        return -1;  
  }  
 assert(env != NULL);  
 if (!registerNatives(env)) {//註冊  
      return -1;  
 }  

//成功  
 result = JNI_VERSION_1_4;  
 return result;  
}  


可以看出在.c文件中的native實現方法中不需要遵循包名_類名_方法名,只需要寫自己想要的方法名就好了;

重寫JNI_OnLoad()方法這樣就會當調用 System.loadLibrary(“XXXX”)方法的時候直接來調用JNI_OnLoad(),這樣就達到了動態註冊實現native方法的作用。

上面代碼中的 JNIEXPORT JNICALL jni 的宏,在 android jni 中不需要,當然寫上去也不會有錯,因爲JNIjava和其它語言通信的一套標準,不是針對android的,所以android會重新定義這兩個宏爲空

注:由於gMethods[]是一個“<名稱,函數指針>”格式,所以可以多次呼叫函數registerNativeMethods()的方式來更換本地函數指針啊

.Binder機制

看書也沒理清楚Binder機制中4個組件的通信關係

Binder框架定義了4個組件角色:Server,Client,ServiceManager(以後簡稱SMgr)以及Binder驅動。其中Server,Client,SMgr運行於用戶空間,驅動運行於內核空間。這四個角色的關係和互聯網類似:Server是服務器,Client是客戶終端,SMgr是域名服務器(DNS),驅動是路由器。

binder驅動提供/dev/binder設備文件與用戶空間進行交互,client/server/smgr通過文件操作函數open()/ioctl()與binder驅動進行通信

總結:

  server、client、 smgr都是運行與獨立的進程;

  1 、smgr 通過open(),/dev/binder打開驅動設備文件,告知binder驅動自己是binder機制的上下文管理者,並且會創建binder 0號,而此時binder驅動也會爲smgr創建一個binder實體,這個binder實體在所有client中可以通過0來獲取到它的引用,稱作0號引用,一切與我通信的進程都要經過0號引用與我打交道,之後smgr進入循環等待其它進程通信 

  2、server爲了提供服務,通過binder驅動以及smgr的0號引用,將server的 binder實體引用與實體名稱註冊到smgr,想要訪問這個服務的就通過這個名稱來獲取binder實體引用

  3、當client要獲取server的服務時,首先通過binder攜帶server的binder的實體名稱,經過binder驅動,binder驅動通過smgr的0號引用和實體名稱從smgr獲取到server的binder實體引用,這樣client和server就建立了一條binder通道,在通道上進行通信

以上是我的理解,有誤之處請不吝賜教

以下抄自網絡:

3.1 Binder 驅動             

和路由器一樣,Binder驅動雖然默默無聞,卻是通信的核心。儘管名叫‘驅動’,實際上和硬件設備沒有任何關係,只是實現方式和設備驅動程序是一樣的。它工作於內核態,驅動負責進程之間Binder通信的建立,Binder在進程之間的傳遞,Binder引用計數管理,數據包在進程之間的傳遞和交互等一系列底層支持。

實現方式和設備驅動程序是一樣的:它工作於內核態,提供open()mmap()poll()ioctl()等標準文件操作,以字符驅動設備中的misc設備註冊在設備目錄/dev下,用戶通過/dev/binder訪問該它。驅動負責進程之間Binder通信的建立,Binder在進程之間的傳遞,Binder引用計數管理,數據包在進程之間的傳遞和交互等一系列底層支持。驅動和應用程序之間定義了一套接口協議,主要功能由ioctl()接口實現,不提供read()write()接口,因爲ioctl()靈活方便,且能夠一次調用實現先寫後讀以滿足同步交互,而不必分別調用write()read()Binder驅動的代碼位於linux目錄的

3.2 ServiceManager 與實名Binder

和DNS類似,SMgr的作用是將字符形式的Binder名字轉化成Client中對該Binder的引用,使得Client能夠通過Binder名字獲得對Server中Binder實體的引用。註冊了名字的Binder叫實名Binder,就象每個網站除了有IP地址外還有自己的網址。Server創建了Binder實體,爲其取一個字符形式,可讀易記的名字,將這個Binder連同名字以數據包的形式通過Binder驅動發送給SMgr,通知SMgr註冊一個名叫張三的Binder,它位於某個Server中。驅動爲這個穿過進程邊界的Binder創建位於內核中的實體節點以及SMgr對實體的引用,將名字及新建的引用打包傳遞給SMgr。SMgr收數據包後,從中取出名字和引用填入一張查找表中。

細心的讀者可能會發現其中的蹊蹺:SMgr是一個進程,Server是另一個進程,Server向SMgr註冊Binder必然會涉及進程間通信。當前實現的是進程間通信卻又要用到進程間通信,這就好象蛋可以孵出雞前提卻是要找只雞來孵蛋。Binder的實現比較巧妙:預先創造一隻雞來孵蛋:SMgr和其它進程同樣採用Binder通信,SMgr是Server端,有自己的Binder對象(實體),其它進程都是Client,需要通過這個Binder的引用來實現Binder的註冊,查詢和獲取。SMgr提供的Binder比較特殊,它沒有名字也不需要註冊,當一個進程使用BINDER_SET_CONTEXT_MGR命令將自己註冊成SMgr時Binder驅動會自動爲它創建Binder實體(這就是那隻預先造好的雞)。其次這個Binder的引用在所有Client中都固定爲0而無須通過其它手段獲得。也就是說,一個Server若要向SMgr註冊自己Binder就必需通過0這個引用號和SMgr的Binder通信。類比網絡通信,0號引用就好比域名服務器的地址,你必須預先手工或動態配置好。要注意這裏說的Client是相對SMgr而言的,一個應用程序可能是個提供服務的Server,但對SMgr來說它仍然是個Client。

3.3 Client 獲得實名Binder的引用

Server向SMgr註冊了Binder實體及其名字後,Client就可以通過名字獲得該Binder的引用了。Client也利用保留的0號引用向SMgr請求訪問某個Binder:我申請獲得名字叫張三的Binder的引用。SMgr收到這個連接請求,從請求數據包裏獲得Binder的名字,在查找表裏找到該名字對應的條目,從條目中取出Binder的引用,將該引用作爲回覆發送給發起請求的Client。從面向對象的角度,這個Binder對象現在有了兩個引用:一個位於SMgr中,一個位於發起請求的Client中。如果接下來有更多的Client請求該Binder,系統中就會有更多的引用指向該Binder,就象java裏一個對象存在多個引用一樣。而且類似的這些指向Binder的引用是強類型,從而確保只要有引用Binder實體就不會被釋放掉。通過以上過程可以看出,SMgr象個火車票代售點,收集了所有火車的車票,可以通過它購買到乘坐各趟火車的票-得到某個Binder的引用。

這裏推薦一篇文章,講的很詳細

http://blog.csdn.net/boyupeng/article/details/47011383

七.VM啓動流程

android中每個應用都運行在一個DVM實例裏,而每個dvm實例都是一個獨立的進程空間;當手機啓動時,linux內核初始化完成後會創建並運行一個init進程,隨後init進程初始化系統外操作以及讀取init腳本初始化各種service,zygote進程就是從腳本的service通知init進程創建而來,起初zygote進程只是名爲app_process進程,經過一系列的代碼修改爲zygote進程,從而zygote進程就作爲所有app進程的父進程;

zygote本身就是應用程序,zygote進程創建後,啓動zygote進程,創建一個虛擬機實例,並註冊虛擬機實例中的jni方法、完成系統framework庫的加載、預置類庫的加載,隨後會調用startSystemServer()啓動一個後臺服務進程,最後會創建socket服務進入循環,等待其他進程服務(比如AMS/WMS/PMS等)的請求;

zygote通過startSystemServer()->Zygote.forkSystemServer()孵化(fork)出system_server進程,隨後syetem_server進程啓動一個serverThread線程,線程主要負責啓動android平臺中的各個service比如android核心服務:ActivityManagerService(AMS)、PacketManagerService(PMS)、WindowManagerService(WMS);藍牙服務;電池服務;通信相關(wifi、telephone)服務;系統相關功能服務AudioService、usb-service;狀態欄、通知服務、剪貼板服務、日誌儲存管理服務、性能統計管理服務等系統所有的service基本都歸於它來啓動,而且將service都要註冊到serverManager當中這就涉及到binder機制通信了。此外system_server進程通過啓動的PMS調用scanDirLI()來掃描手機中的5個目錄中的apk文件,目錄如下

/system/framework

/system/app

/vendor/app

/data/app

/data/app-private

獲取到apk格式的應用程序文件,調用parsePackage()全程解析apk中androidManifest.xml配置文件,將配置文件中activity、service、broadcast、content provider等整個應用程序信息保存到PMS中集合mpkgs(pkgname,pkg),安裝完成,此時應用程序相當於在PMS中完成註冊,想在手機界面看到應用程序,需要通過Home應用程序從PMS中把安裝好的應用程序以快捷圖標的方式顯示在桌面。

當點擊桌面快捷圖標打開app時,AMS調用startProcessLocked()將app進程啓動參數寫到LocalSocket通過LocalSocket連接並通知zygote進程通過Zygote.fofkAndSpecialize()創建一個新的app進程,創建完畢後,RuntimeInit.invokeStaticMian()將ActivityThread作爲新程序主入口,這個ActivityThread就是App的主入口也是主線程。整個過程大概就這樣的。

init進程是用戶空間第一個進程,也是所有應用空間進程的父進程,也就是說應用空間的所有進程都是有init通過folkandspecify()直接或間接fork出來的

zygote進程通過fork(複製)自身,最快速的提供一個系統,對於一些只讀的系統庫,所有虛擬機實例和zygote共享一塊內存區域,所有創建app進程時都是通過zygote進程folk出來的,app進程就有和zygote基本一模一樣的虛擬機實例和共享的內存的數據

如果zygote或system_server進程出現異常,則init會重新創建這兩個進程

探討一下,APK的dex文件時什麼時候解析,轉換成odex或oat文件?

也是通過PMS在安裝應用程序時,將APK中的dex文件

DVM環境下:通過opt工具優化主classes.dex成odex文件,準備好之後纔算是安裝完成,注:除了classes.dex文件其它dex文件不會載入,這也就是multidex的用戶

AVM環境下:直接通過dex2oat()方法進而調用DexFile類中方法對所有dex文件進行校驗並優化最後生成oat可執行文件,oat文件是本地機器碼,所以AVM可以直接執行,不需要像DVM那樣每次運行都要解釋odex文件生成本地機器碼,所以AVM更加快更加節省運行時間

以上是讀完《android虛擬機原理》的理解,此處涉及的源碼很多,感興趣的可以入手一本
不對之處,請不吝指正。



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