Proxy/Stub结构
打个比方,你到自动取款机上去取款;你就是客户,取款机就是你的代理;你不会在乎钱具体放在那里,你只想看到足够或更多的钱从出口出来(这就是com的透明性)。你同银行之间的操作完全是取款机代理实现。 你的取款请求通过取款机,传到另一头,银行的服务器,他也没有必要知道你在哪儿取钱,他所关心的是你的身份,和你取款多少。当他确认你的权限,就进行相应的操作,返回操作结果给取款机,取款机根据服务器返回结果,从保险柜里取出相应数量的钱给你。你取出卡后,操作完成。 取款机不是直接同服务器连接的,他们之间还有一个“存根”,取款机与存根通信,服务器与存根通信。从某种意义上说存根就是服务器的代理。
AIDL 概览
每一个进程都有自己的Dalvik VM实例,都有自己的一块独立的内存,都在自己的内存上存储自己的数据,执行着自己的操作,都在自己的那片狭小的空间里过完自己的一生。每个进程之间都你不知我,我不知你,就像是隔江相望的两座小岛一样,都在同一个世界里,但又各自有着自己的世界。而AIDL,就是两座小岛之间沟通的桥梁。相对于它们而言,我们就好像造物主一样,我们可以通过AIDL来制定一些规则,规定它们能进行哪些交流——比如,它们可以在我们制定的规则下传输一些特定规格的数据。
总之,通过这门语言,我们可以愉快的在一个进程访问另一个进程的数据,甚至调用它的一些方法,当然,只能是特定的方法。
但是,如果仅仅是要进行跨进程通信的话,其实我们还有其他的一些选择,比如 BroadcastReceiver , Messenger 等,但是 BroadcastReceiver 占用的系统资源比较多,如果是频繁的跨进程通信的话显然是不可取的;Messenger 进行跨进程通信时请求队列是同步进行的,无法并发执行,在有些要求多进程的情况下不适用——这种时候就需要使用 AIDL 了。
Android 接口定义语言 (AIDL) 与你可能使用过的其他接口语言 (IDL) 类似。你可以利用它定义客户端与服务均认可的编程接口,以便二者使用进程间通信 (IPC) 进行相互通信。在 Android 中,一个进程通常无法访问另一个进程的内存。因此,为进行通信,进程需将其对象分解成可供操作系统理解的原语,并将其编组为可供您操作的对象。编写执行该编组操作的代码较为繁琐,因此 Android 会使用 AIDL 为您处理此问题。
Android 接口定义语言 (AIDL) 是一款可供用户用来抽象化 IPC 的工具。以在 .aidl
文件中指定的接口为例,各种构建系统都会使用 aidl
二进制文件构造 C++ 或 Java 绑定,以便跨进程使用该接口(无论其运行时环境或位数如何)。
只有在需要不同应用的客户端通过 IPC 方式访问服务,并且希望在服务中进行多线程处理时,您才有必要使用 AIDL。如果您无需跨不同应用执行并发 IPC,则应通过实现 Binder 来创建接口;或者,如果您想执行 IPC,但不需要处理多线程,请使用 Messenger 来实现接口。
工作原理
AIDL 使用 Binder 内核驱动程序进行调用。当您发出调用时,系统会将方法标识符和所有对象打包到某个缓冲区中,然后将其复制到某个远程进程,该进程中有一个 Binder 线程正在等待读取数据。Binder 线程收到某个事务的数据后,该线程会在本地进程中查找原生存根对象,然后此类会解压缩数据并调用本地接口对象。此本地接口对象正是服务器进程所创建和注册的对象。当在同一进程和同一后端中进行调用时,不存在代理对象,因此直接调用即可,无需执行任何打包或解压缩操作。
AIDL使用
基本上它的语法和 Java 是一样的,这里着重的说一下它和 Java 不一样的地方。主要有下面这些点:
- 文件类型:用AIDL书写的文件的后缀是 .aidl,而不是 .java。
- 数据类型:AIDL默认支持一些数据类型,在使用这些数据类型的时候是不需要导包的,但是除了这些类型之外的数据类型,在使用之前必须导包,就算目标文件与当前正在编写的 .aidl 文件在同一个包下——在 Java 中,这种情况是不需要导包的。比如,现在我们编写了两个文件,一个叫做 Book.java ,另一个叫做 BookManager.aidl,它们都在 com.demo.aidldemo 包下 ,现在我们需要在 .aidl 文件里使用 Book 对象,那么我们就必须在 .aidl 文件里面写上 import com.demo.aidldemo.Book; 哪怕 .java 文件和 .aidl 文件就在一个包下。
- 默认支持的数据类型包括:
- Java中的八种基本数据类型,包括 byte,short,int,long,float,double,boolean,char。
- String 类型。
- CharSequence类型。
- List类型:List中的所有元素必须是AIDL支持的类型之一,或者是一个其他AIDL生成的接口,或者是定义的parcelable。List可以使用泛型。
- Map类型:Map中的所有元素必须是AIDL支持的类型之一,或者是一个其他AIDL生成的接口,或者是定义的parcelable。Map是不支持泛型的。
- 定向tag:这是一个极易被忽略的点——这里的“被忽略”指的不是大家都不知道,而是很少人会正确的使用它。在我的理解里,定向 tag 是这样的:AIDL中的定向 tag 表示了在跨进程通信中数据的流向,其中 in 表示数据只能由客户端流向服务端, out 表示数据只能由服务端流向客户端,而 inout 则表示数据可在服务端与客户端之间双向流通。其中,数据流向是针对在客户端中的那个传入方法的对象而言的。in 为定向 tag 的话表现为服务端将会接收到一个那个对象的完整数据,但是客户端的那个对象不会因为服务端对传参的修改而发生变动;out 的话表现为服务端将会接收到那个对象的的空对象,但是在服务端对接收到的空对象有任何修改之后客户端将会同步变动;inout 为定向 tag 的情况下,服务端将会接收到客户端传来对象的完整信息,并且客户端将会同步服务端对该对象的任何变动。
具体的分析大家可以移步这篇博文:你真的理解AIDL中的in,out,inout么?
另外,Java 中的基本类型和 String ,CharSequence 的定向 tag 默认且只能是 in 。还有,请注意,请不要滥用定向 tag ,而是要根据需要选取合适的。全都一上来就用 inout ,等工程大了系统的开销就会大很多,因为排列整理参数的开销是很昂贵的。 - 两种AIDL文件:在我的理解里,所有的AIDL文件大致可以分为两类。一类是用来定义parcelable对象,以供其他AIDL文件使用AIDL中非默认支持的数据类型的。一类是用来定义方法接口,以供系统使用来完成跨进程通信的。可以看到,两类文件都是在“定义”些什么,而不涉及具体的实现,这就是为什么它叫做“Android接口定义语言”。
注:所有的非默认支持数据类型必须通过第一类AIDL文件定义才能被使用。
(thanks to : https://blog.youkuaiyun.com/luoyanglizi/article/details/51980630)
定义 AIDL 接口
必须在 .aidl
文件中使用 Java 编程语言的语法定义 AIDL 接口,然后将其保存至应用的源代码(在 src/
目录中)内,这类应用会托管服务或与服务进行绑定。
在构建每个包含 .aidl
文件的应用时,Android SDK 工具会生成基于该 .aidl
文件的 IBinder
接口,并将其保存到项目的 gen/
目录中。服务必须视情况实现 IBinder
接口。然后,客户端应用便可绑定到该服务,并调用 IBinder
中的方法来执行 IPC。
如要使用 AIDL 创建绑定服务,请执行以下步骤:
- 创建 .aidl 文件
此文件定义带有方法签名的编程接口。
AIDL 使用一种简单语法,允许通过一个或多个方法(可接收参数和返回值)来声明接口。参数和返回值可为任意类型,甚至是 AIDL 生成的其他接口。 必须使用 Java 编程语言构建.aidl
文件。每个.aidl
文件均须定义单个接口,并且只需要接口声明和方法签名。原语默认为in
,不能是其他方向。定义服务接口时,请注意:
方法可带零个或多个参数,返回值或空值。
所有非原语参数均需要指示数据走向的方向标记。这类标记可以是in
、out
或inout
(见下方示例)
生成的IBinder
接口内包含.aidl
文件中的所有代码注释(import 和 package 语句之前的注释除外)。
可以在 ADL 接口中定义 String 常量和 int 字符串常量。
方法调用由 transact() 代码分派,该代码通常基于接口中的方法索引。由于这会增加版本控制的难度,因此您可以向方法手动配置事务代码:void method() = 10;
。
使用@nullable
注释可空参数或返回类型。
我们只需将.aidl
文件保存至项目的src/
目录内,这样在构建应用时,SDK 工具便会在项目的gen/
目录中生成IBinder
接口文件。生成文件的名称与.aidl
文件的名称保持一致,区别在于其使用.java
扩展名(例如,IRemoteService.aidl
生成的文件名是IRemoteService.java
)。
// Book.aidl //第一类AIDL文件的例子 //这个文件的作用是引入了一个序列化对象 Book 供其他的AIDL文件使用 //注意:Book.aidl与Book.java的包名应当是一样的 package com.nxp.aidl1; //注意parcelable是小写 parcelable Book;
// BookManager.aidl //第二类AIDL文件的例子 package com.nxp.aidl1; //导入所需要使用的非默认支持数据类型的包 import com.nxp.aidl1.Book; interface BookManager { //aidl 支持的基本数据类型 //默认生成的方法,可以去掉 void basicTypes(int anInt, long aLong, boolean aBoolean, float aFloat, double aDouble, String aString); //所有的返回值前都不需要加任何东西,不管是什么数据类型 List<Book> getBooks(); Book getBook(); int getBookCount(); //传参时除了Java基本类型以及String,CharSequence之外的类型 //都需要在前面加上定向tag,具体加什么量需而定 void setBookPrice(in Book book , int price) void setBookName(in Book book , String name) void addBookIn(in Book book); void addBookOut(out Book book); void addBookInout(inout Book book); }
此时编译一下工程, 成功后,编写的AIDL文件会根据一定的规则映射生成Java文件。
- 实现接口
当构建应用时,Android SDK 工具会生成以
.aidl
文件命名的.java
接口文件。生成的接口包含一个名为Stub
的子类(例如,YourInterface.Stub
),该子类是其父接口的抽象实现,并且会声明.aidl
文件中的所有方法。在实现 AIDL 接口时,您应注意遵守以下规则:
由于无法保证在主线程上执行传入调用,因此你一开始便需做好多线程处理的准备,并对你的服务进行适当构建,使其达到线程安全的标准。
默认情况下,RPC 调用是同步调用。如果你知道服务完成请求的时间不止几毫秒,则不应从 Activity 的主线程调用该服务,因为这可能会使应用挂起(Android 可能会显示“Application is Not Responding”对话框)— 通常,你应从客户端内的单独线程调用服务。
你引发的任何异常都不会回传给调用方。 - 向客户端公开接口
实现
Service
并重写onBind()
,从而返回Stub
类的实现。
在为服务实现接口后,您需要向客户端公开该接口,以便客户端进行绑定。如要为您的服务公开该接口,请扩展Service
并实现onBind()
,从而返回实现生成的Stub
的类实例。当客户端(如 Activity)调用
bindService()
以连接此服务时,客户端的onServiceConnected()
回调会接收服务的onBind()
方法所返回的binder
实例。客户端还必须拥有接口类的访问权限,因此如果客户端和服务在不同应用内,则客户端应用的
src/
目录内必须包含.aidl
文件(该文件会生成android.os.Binder
接口,进而为客户端提供 AIDL 方法的访问权限)的副本。当客户端在
onServiceConnected()
回调中收到IBinder
时,它必须调用YourServiceInterface.Stub.asInterface(service)
,以将返回的参数转换成YourServiceInterface
类型。
通过 IPC 传递对象
您可以通过 IPC 接口,将某个类从一个进程发送至另一个进程。不过,您必须确保 IPC 通道的另一端可使用该类的代码,并且该类必须支持 Parcelable
接口。支持 Parcelable
接口很重要,因为 Android 系统能通过该接口将对象分解成可编组至各进程的原语。
如要创建支持 Parcelable
协议的类,您必须执行以下操作:
- 让您的类实现
Parcelable
接口。 - 实现
writeToParcel
,它会获取对象的当前状态并将其写入Parcel
。 - 为您的类添加
CREATOR
静态字段,该字段是实现Parcelable.Creator
接口的对象。 - 最后,创建声明 Parcelable 类的
.aidl
文件(遵照下文Rect.aidl
文件所示步骤)。如果您使用的是自定义编译进程,请勿在您的构建中添加
.aidl
文件。此.aidl
文件与 C 语言中的头文件类似,并未经过编译。
AIDL 会在其生成的代码中使用这些方法和字段,以对您的对象进行编组和解编。
package android.graphics;
// Declare Rect so AIDL can find it and knows that it implements
// the parcelable protocol.
parcelable Rect;
import android.os.Parcel;
import android.os.Parcelable;
public final class Rect implements Parcelable {
public int left;
public int top;
public int right;
public int bottom;
public static final Parcelable.Creator<Rect> CREATOR = new Parcelable.Creator<Rect>() {
public Rect createFromParcel(Parcel in) {
return new Rect(in);
}
public Rect[] newArray(int size) {
return new Rect[size];
}
};
public Rect() {
}
private Rect(Parcel in) {
readFromParcel(in);
}
public void writeToParcel(Parcel out, int flags) {
out.writeInt(left);
out.writeInt(top);
out.writeInt(right);
out.writeInt(bottom);
}
public void readFromParcel(Parcel in) {
left = in.readInt();
top = in.readInt();
right = in.readInt();
bottom = in.readInt();
}
public int describeContents() {
return 0;
}
}
调用 IPC 方法
如要调用通过 AIDL 定义的远程接口,调用类必须执行以下步骤:
- 在项目的
src/
目录中加入.aidl
文件。 - 声明一个
IBinder
接口实例(基于 AIDL 生成)。 - 实现
ServiceConnection
。 - 调用
Context.bindService()
,从而传入您的ServiceConnection
实现。 - 在
onServiceConnected()
实现中,您将收到一个IBinder
实例(名为service
)。调用YourInterfaceName.Stub.asInterface((IBinder)service)
,以将返回的参数转换为 YourInterface 类型。 - 调用您在接口上定义的方法。您应始终捕获
DeadObjectException
异常,系统会在连接中断时抛出此异常。您还应捕获SecurityException
异常,当 IPC 方法调用中两个进程的 AIDL 定义发生冲突时,系统会抛出此异常。 - 如要断开连接,请使用您的接口实例调用
Context.unbindService()
。
有关调用 IPC 服务的几点说明:
- 对象是跨进程计数的引用。
- 您可以方法参数的形式发送匿名对象。
实例:使用AIDL文件来完成跨进程通信
在进行跨进程通信的时候,在AIDL中定义的方法里包含非默认支持的数据类型与否,我们要进行的操作是不一样的。如果不包含,那么我们只需要编写一个AIDL文件,如果包含,那么我们通常需要写 n+1 个AIDL文件( n 为非默认支持的数据类型的种类数)——显然,包含的情况要复杂一些。所以我接下来将只介绍AIDL文件中包含非默认支持的数据类型的情况,至于另一种简单些的情况相信大家是很容易从中触类旁通的。
使数据类实现 Parcelable 接口
由于不同的进程有着不同的内存区域,并且它们只能访问自己的那一块内存区域,所以我们不能像平时那样,传一个句柄过去就完事了——句柄指向的是一个内存区域,现在目标进程根本不能访问源进程的内存,那把它传过去又有什么用呢?所以我们必须将要传输的数据转化为能够在内存之间流通的形式。这个转化的过程就叫做序列化与反序列化。简单来说是这样的:比如现在我们要将一个对象的数据从客户端传到服务端去,我们就可以在客户端对这个对象进行序列化的操作,将其中包含的数据转化为序列化流,然后将这个序列化流传输到服务端的内存中去,再在服务端对这个数据流进行反序列化的操作,从而还原其中包含的数据——通过这种方式,我们就达到了在一个进程中访问另一个进程的数据的目的。
若AIDL文件中涉及到的所有数据类型均为默认支持的数据类型,则无此步骤。因为默认支持的那些数据类型都是可序列化的。
而通常,在我们通过AIDL进行跨进程通信的时候,选择的序列化方式是实现 Parcelable 接口。以Book.java为例:
package com.nxp.aidl1;
import android.os.Parcel;
import android.os.Parcelable;
public class Book implements Parcelable {
private int price;
private String name;
public Book(){}
public String getName(){
return name;
}
public void setName(String name) {
this.name = name;
}
public int getPrice() {
return price;
}
public void setPrice(int price) {
this.price = price;
}
public static final Creator<Book> CREATOR = new Creator<Book>() {
@Override
public Book createFromParcel(Parcel in) {
return new Book(in);
}
@Override
public Book[] newArray(int size) {
return new Book[size];
}
};
public Book(Parcel in) {
name = in.readString();
price = in.readInt();
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(name);
dest.writeInt(price);
}
/**
* 参数是一个Parcel,用它来存储与传输数据
* @param dest
*/
public void readFromParcel(Parcel dest) {
//注意,此处的读值顺序应当是和writeToParcel()方法中一致的
name = dest.readString();
price = dest.readInt();
}
//方便打印数据
@Override
public String toString() {
return "name : " + name + " , price : " + price;
}
}
写AIDL文件
// Book.aidl
package com.nxp.aidl1;
// Declare any non-default types here with import statements
parcelable Book;
// BookManager.aidl
package com.nxp.aidl1;
import com.nxp.aidl1.Book;
// Declare any non-default types here with import statements
interface BookManager {
/**
* Demonstrates some basic types that you can use as parameters
* and return values in AIDL.
*/
void basicTypes(int anInt, long aLong, boolean aBoolean, float aFloat,
double aDouble, String aString);
//所有的返回值前都不需要加任何东西,不管是什么数据类型
List<Book> getBooks();
//传参时除了Java基本类型以及String,CharSequence之外的类型都需要在前面加上定向tag,具体加什么量需而定
void addBook(in Book book);
}
写服务端
通过上面几步,我们已经完成了AIDL及其相关文件的全部内容,那么我们究竟应该如何利用这些东西来进行跨进程通信呢?其实,在我们写完AIDL文件并 clean 或者 rebuild 项目之后,编译器会根据AIDL文件为我们生成一个与AIDL文件同名的 .java 文件,这个 .java 文件才是与我们的跨进程通信密切相关的东西。事实上,基本的操作流程就是:在服务端实现AIDL中定义的方法接口的具体逻辑,然后在客户端调用这些方法接口,从而达到跨进程通信的目的。
package com.nxp.aidl1;
import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.os.RemoteException;
import android.util.Log;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
public class AIDLService extends Service {
public final String TAG = this.getClass().getSimpleName();
//包含Book对象的list
private List<Book> mBooks = new ArrayList<>();
public AIDLService() {
}
//由AIDL文件生成的BookManager
//重写 BookManager.Stub 中的方法
private final BookManager.Stub mBookManager = new BookManager.Stub() {
@Override
public void basicTypes(int anInt, long aLong, boolean aBoolean, float aFloat, double aDouble, String aString) throws RemoteException {
}
@Override
public List<Book> getBooks() throws RemoteException {
synchronized (this) {
Log.e(TAG, "invoking getBooks() method , now the list is : " + mBooks.toString());
if (mBooks != null) {
return mBooks;
}
return new ArrayList<>();
}
}
@Override
public void addBook(Book book) throws RemoteException {
synchronized (this) {
if (mBooks == null) {
mBooks = new ArrayList<>();
}
if (book == null) {
Log.e(TAG, "Book is null in In");
book = new Book();
}
//尝试修改book的参数,主要是为了观察其到客户端的反馈
book.setPrice(2333);
if (!mBooks.contains(book)) {
mBooks.add(book);
}
//打印mBooks列表,观察客户端传过来的值
Log.e(TAG, "invoking addBooks() method , now the list is : " + mBooks.toString());
}
}
};
//初始化
@Override
public void onCreate() {
super.onCreate();
Book book = new Book();
book.setName("Android开发艺术探索");
book.setPrice(28);
mBooks.add(book);
}
//重写 onBind() 方法
@Nullable
@Override
public IBinder onBind(Intent intent) {
Log.e(getClass().getSimpleName(), String.format("on bind,intent = %s", intent.toString()));
return mBookManager;
}
}
接下来在 Manefest 文件里面注册这个我们写好的 Service
<service
android:name=".AIDLService"
android:enabled="true"
android:exported="true"
android:process='.aidl'>
<intent-filter>
<action android:name="com.nxp.aidl1"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</service>
写客户端
在客户端我们要完成的工作主要是连接上服务端,调用服务端的方法
package com.nxp.aidl1;
import androidx.appcompat.app.AppCompatActivity;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.Toast;
import java.util.List;
public class MainActivity extends AppCompatActivity {
private Button button;
//由AIDL文件生成的Java类
private BookManager mBookManager = null;
//标志当前与服务端连接状况的布尔值,false为未连接,true为连接中
private boolean mBound = false;
//包含Book对象的list
private List<Book> mBooks;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
/**
* 按钮的点击事件,点击之后调用服务端的addBookIn方法
*
* @param view
*/
public void addBook(View view) {
//如果与服务端的连接处于未连接状态,则尝试连接
if (!mBound) {
attemptToBindService();
Toast.makeText(this, "当前与服务端处于未连接状态,正在尝试重连,请稍后再试", Toast.LENGTH_SHORT).show();
return;
}
if (mBookManager == null) return;
Book book = new Book();
book.setName("APP研发");
book.setPrice(30);
try {
mBookManager.addBook(book);
Log.e(getLocalClassName(), book.toString());
} catch (RemoteException e) {
e.printStackTrace();
}
}
/**
* 尝试与服务端建立连接
*/
private void attemptToBindService() {
Intent intent = new Intent();
intent.setAction("com.nxp.aidl1");
intent.setPackage("com.nxp.aidl1");
bindService(intent, mServiceConnection, Context.BIND_AUTO_CREATE);
}
@Override
protected void onStart() {
super.onStart();
if (!mBound) {
attemptToBindService();
}
}
@Override
protected void onStop() {
super.onStop();
if (mBound) {
unbindService(mServiceConnection);
mBound = false;
}
}
private ServiceConnection mServiceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
Log.e(getLocalClassName(), "service connected");
mBookManager = BookManager.Stub.asInterface(service);
mBound = true;
if (mBookManager != null) {
try {
mBooks = mBookManager.getBooks();
Log.e(getLocalClassName(), mBooks.toString());
} catch (RemoteException e) {
e.printStackTrace();
}
}
}
@Override
public void onServiceDisconnected(ComponentName name) {
Log.e(getLocalClassName(), "service disconnected");
mBound = false;
}
};
}
通信测试
调用客户端activity的 addBook() 方法,我们会看到服务端和客户端的 logcat 信息是这样的
6117-6117/com.nxp.aidl1 E/MainActivity: service connected
6117-6117/com.nxp.aidl1 E/MainActivity: [name : Android开发艺术探索 , price : 28]
6117-6117/com.nxp.aidl1 E/MainActivity: name : APP研发录 , price : 30
6117-6117/com.nxp.aidl1 E/MainActivity: name : APP研发录 , price : 30
6117-6117/com.nxp.aidl1 E/MainActivity: name : APP研发录 , price : 30
6117-6117/com.nxp.aidl1 E/MainActivity: name : APP研发录 , price : 30
文中工程代码下载链接:DemoCode
关于AIDL的一些很好的博文:
https://blog.youkuaiyun.com/luoyanglizi/article/details/51980630
https://blog.youkuaiyun.com/luoyanglizi/article/details/52029091
https://blog.youkuaiyun.com/qian520ao/article/details/78072250
https://blog.youkuaiyun.com/tgbus18990140382/article/details/52799899
https://www.jianshu.com/p/1711488071ae
https://blog.youkuaiyun.com/luoyanglizi/article/details/51958091
https://blog.youkuaiyun.com/qian520ao/article/details/78072250
https://blog.youkuaiyun.com/weixin_46880398/article/details/105979353