前言
上一篇博文介紹了關(guān)于AIDL是什么,為什么我們需要AIDL,AIDL的語法以及如何使用AIDL等方面的知識,這一篇博文將順著上一篇的思路往下走,接著介紹關(guān)于AIDL的一些更加深入的知識。強烈建議大家在看這篇博文之前先看一下上一篇博文:Android:學(xué)習(xí)AIDL,這一篇文章就夠了(上)
注:文中所有代碼均源自上一篇博文中的例子。
另:在看這篇博文之前,建議先將上一篇博文中的代碼下載下來或者敲一遍,然后確定可以正常運行后再接著看。因為文中有大量對于具體代碼的分析以及相關(guān)代碼片段之間的跳轉(zhuǎn),如果你手頭沒有一份完整代碼的話很容易看得一頭霧水,最后浪費了你的時間也浪費了這篇博文。
正文
1,源碼分析:AIDL文件是怎么工作的?
進行到上一篇文章的最后一步,我們已經(jīng)學(xué)會了AIDL的全部用法,接下來讓我們透過現(xiàn)象看本質(zhì),研究一下究竟AIDL是如何幫助我們進行跨進程通信的。
我們在上一篇提到過,在寫完AIDL文件后,編譯器會幫我們自動生成一個同名的 .java 文件——也許大家已經(jīng)發(fā)現(xiàn)了,在我們實際編寫客戶端和服務(wù)端代碼的過程中,真正協(xié)助我們工作的其實是這個文件,而 .aidl 文件從頭到尾都沒有出現(xiàn)過。這樣一來我們就很容易產(chǎn)生一個疑問:難道我們寫AIDL文件的目的其實就是為了生成這個文件么?答案是肯定的。事實上,就算我們不寫AIDL文件,直接按照它生成的 .java 文件那樣寫一個 .java 文件出來,在服務(wù)端和客戶端中也可以照常使用這個 .java 類來進行跨進程通信。所以說AIDL語言只是在簡化我們寫這個 .java 文件的工作而已,而要研究AIDL是如何幫助我們進行跨進程通信的,其實就是研究這個生成的 .java 文件是如何工作的。
1.1,這個文件在哪兒?
要研究它,首先我們就需要找到它,那么它在哪兒呢?在這里:
它的完整路徑是:app->build->generated->source->aidl->debug->com->lypeer->ipcclient->BookManager.java(其中 com.lypeer.ipcclient
是包名,相對應(yīng)的AIDL文件為 BookManager.aidl )。在Android Studio里面目錄組織方式由默認(rèn)的 Android 改為 Project 就可以直接按照文件夾結(jié)構(gòu)訪問到它。
1.2,從應(yīng)用看原理
和我一貫的分析方式一樣,我們先不去看那些冗雜的源碼,先從它在實際中的應(yīng)用著手,輔以思考分析,試圖尋找突破點。首先從服務(wù)端開始,刨去其他與此無關(guān)的東西,從宏觀上我們看看它干了些啥:
private final BookManager.Stub mBookManager = new BookManager.Stub() {
@Override
public List<Book> getBooks() throws RemoteException {
// getBooks()方法的具體實現(xiàn)
}
@Override
public void addBook(Book book) throws RemoteException {
// addBook()方法的具體實現(xiàn)
}
};
public IBinder onBind(Intent intent) {
return mBookManager;
}
可以看到首先我們是對 BookManager.Stub 里面的抽象方法進行了重寫——實際上,這些抽象方法正是我們在 AIDL 文件里面定義的那些。也就是說,我們在這里為我們之前定義的方法提供了具體實現(xiàn)。接著,在 onBind() 方法里我們將這個 BookManager.Stub 作為返回值傳了過去。
接著看看客戶端:
private BookManager mBookManager = null;
private ServiceConnection mServiceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service)
mBookManager = BookManager.Stub.asInterface(service);
//省略
}
@Override
public void onServiceDisconnected(ComponentName name) {
//省略
}
};
public void addBook(View view) {
//省略
mBookManager.addBook(book);
}
簡單的來說,客戶端就做了這些事:獲取 BookManager 對象,然后調(diào)用它里面的方法。
現(xiàn)在結(jié)合服務(wù)端與客戶端做的事情,好好思考一下,我們會發(fā)現(xiàn)這樣一個怪事情:它們配合的如此緊密,以至于它們之間的交互竟像是同一個進程中的兩個類那么自然!大家可以回想下平時項目里的接口回調(diào),基本流程與此一般無二。明明是在兩個線程里面,數(shù)據(jù)不能直接互通,何以他們能交流的如此愉快呢?答案在 BookManager.java 里。
1.3,從客戶端開始
一點開 BookManager.java ,我發(fā)現(xiàn)的第一件事是:BookManager 是一個接口類!一看到它是個接口,我就知道,突破口有了。為什么呢?接口意味著什么?方法都沒有具體實現(xiàn)。但是明明在客戶端里面我們調(diào)用了 mBookManager.addBook() !那么就說明我們在客戶端里面用到的 BookManager 絕不僅僅是 BookManager,而是它的一個實現(xiàn)類!那么我們就可以從這個實現(xiàn)類入手,看看在我們的客戶端調(diào)用 addBook() 方法的時候,究竟 BookManager 在背后幫我們完成了哪些操作。首先看下客戶端的 BookManager 對象是怎么來的:
public void onServiceConnected(ComponentName name, IBinder service)
mBookManager = BookManager.Stub.asInterface(service);
}
在這里我首先注意到的是方法的傳參:IBinder service 。這是個什么東西呢?通過調(diào)試,我們可以發(fā)現(xiàn),這是個 BinderProxy 對象。但隨后我們會驚訝的發(fā)現(xiàn):Java中并沒有這個類!似乎研究就此陷入了僵局——其實不然。在這里我們沒辦法進一步的探究下去,那我們就先把這個問題存疑,從后面它的一些應(yīng)用來推測關(guān)于它的更多的東西。
接下來順藤摸瓜去看下這個 BookManager.Stub.asInterface() 是怎么回事:
public static com.lypeer.ipcclient.BookManager asInterface(android.os.IBinder obj) {
//驗空
if ((obj == null)) {
return null;
}
//DESCRIPTOR = "com.lypeer.ipcclient.BookManager",搜索本地是否已經(jīng)
//有可用的對象了,如果有就將其返回
android.os.IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
if (((iin != null) && (iin instanceof com.lypeer.ipcclient.BookManager))) {
return ((com.lypeer.ipcclient.BookManager) iin);
}
//如果本地沒有的話就新建一個返回
return new com.lypeer.ipcclient.BookManager.Stub.Proxy(obj);
}
方法里首先進行了驗空,這個很正常。第二步操作是調(diào)用了 queryLocalInterface() 方法,這個方法是 IBinder 接口里面的一個方法,而這里傳進來的 IBinder 對象就是上文我們提到過的那個 service 對象。由于對 service 對象我們還沒有一個很清晰的認(rèn)識,這里也沒法深究這個 queryLocalInterface() 方法:它是 IBinder 接口里面的一個方法,那么顯然,具體實現(xiàn)是在 service 的里面的,我們無從窺探。但是望文生義我們也能體會到它的作用,這里就姑且這么理解吧。第三步是創(chuàng)建了一個對象返回——很顯然,這就是我們的目標(biāo),那個實現(xiàn)了 BookManager 接口的實現(xiàn)類。果斷去看這個 BookManager.Stub.Proxy 類:
private static class Proxy implements com.lypeer.ipcclient.BookManager {
private android.os.IBinder mRemote;
Proxy(android.os.IBinder remote) {
//此處的 remote 正是前面我們提到的 IBinder service
mRemote = remote;
}
@Override
public java.util.List<com.lypeer.ipcclient.Book> getBooks() throws android.os.RemoteException {
//省略
}
@Override
public void addBook(com.lypeer.ipcclient.Book book) throws android.os.RemoteException {
//省略
}
//省略部分方法
}
看到這里,我們幾乎可以確定:Proxy 類確實是我們的目標(biāo),客戶端最終通過這個類與服務(wù)端進行通信。
那么接下來看看 getBooks() 方法里面具體做了什么:
@Override
public java.util.List<com.lypeer.ipcclient.Book> getBooks() throws android.os.RemoteException {
//很容易可以分析出來,_data用來存儲流向服務(wù)端的數(shù)據(jù)流,
//_reply用來存儲服務(wù)端流回客戶端的數(shù)據(jù)流
android.os.Parcel _data = android.os.Parcel.obtain();
android.os.Parcel _reply = android.os.Parcel.obtain();
java.util.List<com.lypeer.ipcclient.Book> _result;
try {
_data.writeInterfaceToken(DESCRIPTOR);
//調(diào)用 transact() 方法將方法id和兩個 Parcel 容器傳過去
mRemote.transact(Stub.TRANSACTION_getBooks, _data, _reply, 0);
_reply.readException();
//從_reply中取出服務(wù)端執(zhí)行方法的結(jié)果
_result = _reply.createTypedArrayList(com.lypeer.ipcclient.Book.CREATOR);
} finally {
_reply.recycle();
_data.recycle();
}
//將結(jié)果返回
return _result;
}
在這段代碼里有幾個需要說明的地方,不然容易看得云里霧里的:
- 關(guān)于 _data 與 _reply 對象:一般來說,我們會將方法的傳參的數(shù)據(jù)存入_data 中,而將方法的返回值的數(shù)據(jù)存入 _reply 中——在沒涉及定向 tag 的情況下。如果涉及了定向 tag ,情況將會變得稍微復(fù)雜些,具體是怎么回事請參見這篇博文:你真的理解AIDL中的in,out,inout么?
- 關(guān)于 Parcel :簡單的來說,Parcel 是一個用來存放和讀取數(shù)據(jù)的容器。我們可以用它來進行客戶端和服務(wù)端之間的數(shù)據(jù)傳輸,當(dāng)然,它能傳輸?shù)闹荒苁强尚蛄谢臄?shù)據(jù)。具體 Parcel 的使用方法和相關(guān)原理可以參見這篇文章:Android中Parcel的分析以及使用
- 關(guān)于 transact() 方法:這是客戶端和服務(wù)端通信的核心方法。調(diào)用這個方法之后,客戶端將會掛起當(dāng)前線程,等候服務(wù)端執(zhí)行完相關(guān)任務(wù)后通知并接收返回的 _reply 數(shù)據(jù)流。關(guān)于這個方法的傳參,這里有兩點需要說明的地方:
- 方法 ID :transact() 方法的第一個參數(shù)是一個方法 ID ,這個是客戶端與服務(wù)端約定好的給方法的編碼,彼此一一對應(yīng)。在AIDL文件轉(zhuǎn)化為 .java 文件的時候,系統(tǒng)將會自動給AIDL文件里面的每一個方法自動分配一個方法 ID。
- 第四個參數(shù):transact() 方法的第四個參數(shù)是一個 int 值,它的作用是設(shè)置進行 IPC 的模式,為 0 表示數(shù)據(jù)可以雙向流通,即 _reply 流可以正常的攜帶數(shù)據(jù)回來,如果為 1 的話那么數(shù)據(jù)將只能單向流通,從服務(wù)端回來的 _reply 流將不攜帶任何數(shù)據(jù)。
注:AIDL生成的 .java 文件的這個參數(shù)均為 0。
上面的這些如果要去一步步探究出結(jié)果的話也不是不可以,但是那將會涉及到 Binder 機制里比較底層的東西,一點點說完勢必會將文章的重心帶偏,那樣就不好了——所以我就直接以上帝視角把結(jié)論給出來了。
另外的那個 addBook() 方法我就不去分析了,殊途同歸,只是由于它涉及到了定向 tag ,所以有那么一點點的不一樣,有興趣的讀者可以自己去試著閱讀一下。接下來我總結(jié)一下在 Proxy 類的方法里面一般的工作流程:
- 1,生成 _data 和 _reply 數(shù)據(jù)流,并向 _data 中存入客戶端的數(shù)據(jù)。
- 2,通過 transact() 方法將它們傳遞給服務(wù)端,并請求服務(wù)端調(diào)用指定方法。
- 3,接收 _reply 數(shù)據(jù)流,并從中取出服務(wù)端傳回來的數(shù)據(jù)。
縱觀客戶端的所有行為,我們不難發(fā)現(xiàn),其實一開始我們不能理解的那個 IBinder service 恰恰是客戶端與服務(wù)端通信的靈魂人物——正是通過用它調(diào)用的 transact() 方法,我們得以將客戶端的數(shù)據(jù)和請求發(fā)送到服務(wù)端去。從這個角度來看,這個 service 就像是服務(wù)端在客戶端的代理一樣——你想要找服務(wù)端?要傳數(shù)據(jù)過去?行?。∧銇碚椅?,我給你把數(shù)據(jù)送過去——而 BookManager.java 中的那個 Proxy 類,就只能淪為二級代理了,我們在外部通過它來調(diào)動 service 對象。
至此,客戶端在 IPC 中進行的工作已經(jīng)分析完了,接下來我們看一下服務(wù)端。
1.4,接著看服務(wù)端
前面說了客戶端通過調(diào)用 transact() 方法將數(shù)據(jù)和請求發(fā)送過去,那么理所當(dāng)然的,服務(wù)端應(yīng)當(dāng)有一個方法來接收這些傳過來的東西:在 BookManager.java 里面我們可以很輕易的找到一個叫做 onTransact() 的方法——看這名字就知道,多半和它脫不了關(guān)系,再一看它的傳參 __(int code, android.os.Parcel data, android.os.Parcel reply, int flags)
__——和 transact() 方法的傳參是一樣的!如果說他們沒有什么 py 交易把我眼珠子挖出來當(dāng)泡踩!下面來看看它是怎么做的:
@Override
public boolean onTransact(int code, android.os.Parcel data, android.os.Parcel reply, int flags) throws android.os.RemoteException {
switch (code) {
case INTERFACE_TRANSACTION: {
reply.writeString(DESCRIPTOR);
return true;
}
case TRANSACTION_getBooks: {
//省略
return true;
}
case TRANSACTION_addBook: {
//省略
return true;
}
}
return super.onTransact(code, data, reply, flags);
}
可以看到,它在接收了客戶端的 transact() 方法傳過來的參數(shù)后,什么廢話都沒說就直接進入了一個 switch 選擇:根據(jù)傳進來的方法 ID 不同執(zhí)行不同的操作。接下來看一下每個方法里面它具體做了些什么,以 getBooks() 方法為例:
case TRANSACTION_getBooks: {
data.enforceInterface(DESCRIPTOR);
//調(diào)用 this.getBooks() 方法,在這里開始執(zhí)行具體的事務(wù)邏輯
//result 列表為調(diào)用 getBooks() 方法的返回值
java.util.List<com.lypeer.ipcclient.Book> _result = this.getBooks();
reply.writeNoException();
//將方法執(zhí)行的結(jié)果寫入 reply ,
reply.writeTypedList(_result);
return true;
}
非常的簡單直了,直接調(diào)用服務(wù)端這邊的具體方法實現(xiàn),然后獲取返回值并將其寫入 reply 流——當(dāng)然,這是由于這個方法沒有傳入?yún)?shù)并且不涉及定向 tag 的關(guān)系,不然還會涉及到將傳入?yún)?shù)從 data 中讀取出來,以及針對定向 tag 的操作,具體的可以參考這篇博文:你真的理解AIDL中的in,out,inout么?。
另外,還有一個問題,有些讀者可能會疑惑,為什么這里沒有看到關(guān)于將 reply 回傳到客戶端的相關(guān)代碼?事實上,在客戶端我們也沒有看到它將相關(guān)參數(shù)傳向服務(wù)端的相關(guān)代碼——它只是把這些參數(shù)都傳入了一個方法,其中過程同樣是對我們隱藏的——服務(wù)端也同樣,在執(zhí)行完 return true 之后系統(tǒng)將會把 reply 流傳回客戶端,具體是怎么做的就不足為外人道也了。不知道大家發(fā)現(xiàn)了沒有,通過隱藏了這些細(xì)節(jié),我們在 transact() 與 onTransact() 之間的調(diào)用以及數(shù)據(jù)傳送看起來就像是發(fā)生在同一個進程甚至同一個類里面一樣。我們的操作就像是在一條直線上面走,根本感受不出來其中原來有過曲折——也許這套機制在設(shè)計之初,就是為了達(dá)到這樣的目的。
分析到這里,服務(wù)端的工作我們也分析的差不多了,下面我們總結(jié)一下服務(wù)端的一般工作流程:
- 1,獲取客戶端傳過來的數(shù)據(jù),根據(jù)方法 ID 執(zhí)行相應(yīng)操作。
- 2,將傳過來的數(shù)據(jù)取出來,調(diào)用本地寫好的對應(yīng)方法。
- 3,將需要回傳的數(shù)據(jù)寫入 reply 流,傳回客戶端。
1.5,總結(jié)
現(xiàn)在我們已經(jīng)完成了 BookManager.java 幾乎所有的分析工作,接下來我想用兩張圖片來做一個總結(jié)。第一張是它的 UML 結(jié)構(gòu)圖:
第二張是客戶端與服務(wù)端使用其進行 IPC 的工作流程:
剩下的就大家自己體味一下吧——如果前面的東西你看懂了,這里有沒有我說的幾句總結(jié)都差不多;如果前面你看的似懂非懂,看看這兩張圖片也就懂了;如果前面你幾乎沒有看懂,那么我寫幾句總結(jié)你還是看不懂。。。
2,為什么要這樣設(shè)計?
這個問題可以拆分成兩個子問題:
- 為什么AIDL的語法要這樣設(shè)計?
- 為什么它生成的 .java 文件的結(jié)構(gòu)要這樣設(shè)計?
首先我有一個總的觀點:在程序設(shè)計領(lǐng)域,任何的解決方案,無非是基于需求和性能兩方面的考慮。首先是保證把需求完成,在這個大前提下保證性能最佳——這里的性能,就包括了代碼的健壯性,可維護性等等林林總總的東西。
關(guān)于AIDL的語法為什么要這么設(shè)計,其實沒有太大的研究的必要——因為他的語法實際上和 Java 沒有多大區(qū)別,區(qū)別的地方也很容易想通,多是因為一些很顯然的原因而不得不那樣做。接下來我主要分析一下 BookManager.java 的設(shè)計之道。首先我們要明確需求:
- 基本需求當(dāng)然是實現(xiàn) IPC 。
- 在此基礎(chǔ)上要盡可能的對開發(fā)者友好,即使用方便,且最好讓開發(fā)者有那種在同一個進程中調(diào)用方法傳輸數(shù)據(jù)的爽感。
既然要實現(xiàn) IPC ,一些核心的要素就不能少,比如客戶端接收到的 IBinder service ,比如 transact() 方法,比如 onTransact() 方法——但是能讓開發(fā)者察覺到這些這些東西的存在甚至自己寫這些東西么?不能。為什么?因為這些東西做的事情其實非常的單調(diào),無非就是那么幾步,但是偏偏又涉及到很多對數(shù)據(jù)的寫入讀出的操作——涉及到數(shù)據(jù)流的東西一般都很繁瑣。把這些東西暴露出去顯然是不合適的,還是建立一套模板把它封裝起來比較的好。但是歸根結(jié)底,我們實現(xiàn) IPC 是需要用到它們的,所以我們需要有一種途徑去訪問它們——在這個時候,代理-樁的設(shè)計理念就初步成型了。為了達(dá)到我們的目的,我們可以在客戶端建立一個服務(wù)端的代理,在服務(wù)端建立一個客戶端的樁,這樣一來,客戶端有什么需求可以直接跟代理說,代理跟它說你等等,我馬上給你處理,然后它就告訴樁,客戶端有這個需求了,樁就馬上讓服務(wù)端開始執(zhí)行相應(yīng)的事件,在執(zhí)行結(jié)束后再通過樁把結(jié)果告訴代理,代理最后把結(jié)果給客戶端。這樣一來,客戶端以為代理就是服務(wù)端,并且事實上它也只與代理進行了交互,而客戶端與代理是在同一個進程中的,在服務(wù)端那邊亦然——通過這種方式,我們就可以讓客戶端與服務(wù)端的通信看上去簡單無比,像是從頭到尾我們都在一個進程中工作一樣。
在上面的設(shè)計思想指導(dǎo)之下,BookManager.java 為什么是我們看到的這個樣子就很清楚明白了。
3,有沒有更好的方式來完成 IPC ?
首先我要闡述的觀點是:如果你對這篇文章中上面敘述的那些內(nèi)容有一定的掌握與理解了的話,完全脫離AIDL來手動書寫客戶端與服務(wù)端的相關(guān)文件來進行 IPC 是絕對沒有問題的。并且在了解了 IPC 得以進行的根本之后,你甚至完全沒有必要照著 BookManager.java 來寫,只要那幾個點在,你想怎么寫就怎么寫。
但是要說明的是,相較于使用AIDL來進行IPC,手動實現(xiàn)基本上是沒有什么優(yōu)勢的。畢竟AIDL是一門用來簡化我們的工作的語言,用它確實可以省很多事。
那么現(xiàn)在除了AIDL與自己手動寫,有沒有其他的方式來進行 IPC 呢?答案是:有的。前段時間餓了么(這不算打廣告吧。。。畢竟沒有利益相關(guān),只是純粹的討論技術(shù))的一個工程師開源了一套 IPC 的框架,地址在這里:Hermes。這套框架的核心還是 IBinder service , transact() ,onTransact() 那些東西(事實上,任何和IPC有關(guān)的操作最終都還是要落在這些東西上面),但是他采取了一種巧妙的方式來實現(xiàn):在服務(wù)端開啟了一條默認(rèn)進程,讓這條進程來負(fù)責(zé)所有針對服務(wù)端的請求,同時采用注解的方式來注冊類和方法,使得客戶端能用這種形式和服務(wù)端建立約定,并且,這個框架對綁定service的那些細(xì)節(jié)隱藏的比較好,我們甚至都不需要在服務(wù)端寫service,在客戶端調(diào)用 bindService了——三管齊下,使得我們可以遠(yuǎn)離以前那些煩人的有關(guān)service的操作了。但是也并不是說這套框架就完全超越了AIDL,在某些方面它也有一些不足。比如,不知道是他的那個 Readme 寫的太晦澀了還是怎么回事,我覺得使用它需要付出的學(xué)習(xí)成本還是比較大的;另外,在這套框架里面是將所有傳向服務(wù)端的數(shù)據(jù)都放在一個 Mail 類里面的,而這個類的傳輸方式相當(dāng)于AIDL里面定向 tag 為 in 的情況——也就是說,不要再想像AIDL里面那樣客戶端數(shù)據(jù)還能在服務(wù)端完成操作之后同步變化了。更多的東西我也還沒看出來,還沒用過這個框架,只是簡單的看了下它的源碼,不過總的來說能過看出來的是作者寫的很用心,作者本身的Android功底也很強大,至少不知道比我強大到哪里去了......另外,想微微的吐槽一下,為什么這個框架用來進行IPC的核心類 IHermesService 里面長得和AIDL生成的 .java 一模一樣啊一模一樣......
總之,我想說的就是,雖然已經(jīng)有AIDL了,但是并不意味著就不會出現(xiàn)比它更好的實現(xiàn)了——不止在這里是這樣,這個觀點可以推廣到所有領(lǐng)域。
結(jié)語
這篇文章說是學(xué)習(xí)AIDL的,其實大部分的內(nèi)容都是在通過AIDL生成的那個.java 文件講 IPC 相關(guān)的知識——其實也就是 Binder 機制的利用的一部分——這也是為什么文中其實有很多地方?jīng)]有深入下去講,而是匆匆忙忙的給出了結(jié)論,因為再往下就不是應(yīng)用層的東西了,講起來比較麻煩,而且容易把人看煩。
講到這里,基本上關(guān)于Android里面 IPC 相關(guān)的東西都已經(jīng)講得差不多了,如果你是從我寫的 Android中的Service:默默的奉獻(xiàn)者 (1) --> Android中的Service:Binder,Messenger,AIDL(2) --> Android:學(xué)習(xí)AIDL,這一篇文章就夠了(上) --> 現(xiàn)在這篇,這樣一路看下來,并且是認(rèn)真的看下來的話,基本上這一塊的問題都難不倒你了。
另外,除了知識,我更希望通過我的博文傳遞的是一些解決問題分析問題的思路或者說是方法,所以我的很多博文都重在敘述思考過程而不是闡述結(jié)果——這樣有好處也有壞處,好處是如果看懂了,能夠收獲更多,壞處是,大部分人都沒有那個耐性慢慢的來看懂它,畢竟這需要思考,而當(dāng)前很多的人都已經(jīng)沒有思考的時間,甚至喪失思考的能力了。
謝謝大家。
另:關(guān)于脫離AIDL自己寫IPC的代碼,我自己寫了一份,大家可以聊作參考,傳送門。