前言
我們知道,Android的底層是使用Linux內核運行的,而Linux為了保證系統的穩定性,使用的是進程隔離的機制,也就是讓不同的進程運行在不同的虛擬空間,使得不同進程之間無法共享數據,防止數據的篡改。但是,有時候我們也會遇到不同進程間需要通信的情況,那么,這時候就需要使用Android系統進程間通信IPC(Inter-Process Communication)。
IPC進程間通信方式
*RPC(Remote Procedure Call的縮寫) 是遠程進程調用的意思。
*Binder線程池最大數為16,超過的請求會被阻塞。在進程間通信時處理并發時,如ContentProvider的CRUD(增刪改查)操作方法最多有16個線程同時工作。
一、Binder的產生
在Linux中規定一個進程分為用戶空間和內核空間。區別是,用戶空間的數據不可在進程間共享,為了保證數據安全性 、獨立性,即進程隔離。而內核空間可以被共享且所有用戶空間共享一個內核空間。用戶空間與內核空間的通信是使用系統調用:
copy_from_user();//將用戶空間的數據拷貝到內核空間
copy_to_user();//將內核空間的數據拷貝到用戶空間
傳統跨進程通信的基本原理:
- “進程1”通過系統調用copy_form_user() 將需要發送的數據拷貝到Linux進程的內核空間中的緩沖區。(拷貝第一次)
- 內核服務程序喚醒“進程2”的接收線程,通過系統調用copy_to_user() 將數據發送到用戶空間。(拷貝第二次)
由于傳統的跨進程通信需拷貝數據2次,且接收方事先并不知道需要創建多大的緩存空間用來接收數據,造成了資源浪費。而采用Binder機制實現通信的時候,通過內存映射調用Linux下的系統調用方法mmap(),只需系統調用copy_from_user()拷貝1次數據就可以實現進程間傳遞數據,節省了內存,提高了效率。
1、內存映射
通過關聯一個進程中的一個虛擬內存區域和一個磁盤上的對象,使得二者存在映射關系。被映射的對象稱為共享對象。當多個進程的虛擬內存區域和同一個共享對象建立映射關系時,對進程1的虛擬內存區域進行寫操作時,也會映射到進程2中的虛擬內存區域。
2、 系統調用mmap()
該函數主要是創建虛擬內存區域 與 共享對象建立映射關系。用內存讀寫代替 I/O讀寫。
/**
* 函數原型
*/
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
內部原理:
- 步驟1:創建虛擬內存區域
- 步驟2:實現地址映射關系(進程的虛擬地址空間關聯到共享對象)
使用時:
用戶進程直接調用mmap()建立映射。
/**
* MAP_SIZE的接收緩存區大小 , 關聯到共享對象中,即建立映射
*/
mmap(NULL, MAP_SIZE, PROT_READ, MAP_PRIVATE, fd, 0);
二、Binder機制
1. 定義
Binder是Android中的一個類,它實現了IBinder接口。
從IPC角度來說,Binder是Android中的一種跨進程通信方式,Binder還可以理解為一種虛擬的物理設備,它的設備驅動是/dev/binder,該通信方式在Linux中沒有。
從Android Framework角度來說,Binder是ServiceManager連接各種Manager(ActivityManager、WindowManager等等)和相應ManagerService的橋梁。
從Android應用層來說,Binder是客戶端和服務端進行通信的媒介,當bindService的時候,服務端會返回一個包含了服務端業務調用的Binder對象,通過Binder對象,客戶端就可以獲取服務端提供的服務或者數據。這里的服務包括普通服務和基于AIDL的服務。
2. 模型
從字面上理解binder是"粘結劑"的意思,那么google的工程師為什會以"粘結劑"來命名binder呢?這是因為binder是基于C-S架構,而在這個模型中存在著四個角色,如下:
3. 模式原理
Client、Server 和 Service Manager 屬于進程的用戶空間,不可進行進程間交互。Binder驅動在內核空間中,能持有Server服務端進程的Binder實體,并給Client客戶端提供Binder實體的引用。
Binder驅動 和 Service Manager進程 屬于 Android基礎架構;而Client進程 和 Server進程 屬于Android應用層。
4. 具體說明
三、AIDL實例
假設一個場景:在圖書館,有兩個進程。A進程是圖書管理員,B進程向A進程添加圖書。當B進程收到A進程添加的圖書時,通知A進程,圖書已經添加成功。了解場景之后,我們通過aidl來實現這個需求。
下文貼出binder服務端與客戶端的代碼。
1、binder服務端
使用一個遠程Service模擬進程A,提供binder服務端。
package com.shine.binderdemo;
import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.os.RemoteException;
import java.util.concurrent.CopyOnWriteArrayList;
public class MyService extends Service {
private CopyOnWriteArrayList<Book> booklist;
private IOnBookAddListener mListener;
private IBinder mBinder = new IBookManager.Stub() {
@Override
public void addBook(Book book) throws RemoteException {
booklist.add(book);
if (mListener != null) {
mListener.onBookadd(book);
}
}
@Override
public void registerListener(IOnBookAddListener listener) throws RemoteException {
mListener = listener;
}
};
@Override
public void onCreate() {
super.onCreate();
booklist = new CopyOnWriteArrayList();
}
@Override
public IBinder onBind(Intent intent) {
return mBinder;
}
}
在AndroidManifest.xml中通過process屬性使MyService 運行在其他進程。
有一點我們需要格外注意:我們看到在用來存放添加書籍的booklist是一個CopyOnWriteArrayList。為什么在這要用CopyOnWriteArrayList呢?這是因為binder服務端在接收到客戶端的請求訪問時都會在一個單獨的線程中處理這個請求,所以會出現線程安全問題,在這個地方通過CopyOnWriteArrayList來避免出現的線程安全問題,這個過程在子線程中完成也可以通過客戶端的代碼來驗證。
2、binder客戶端
在activity中綁定服務來模擬進程B,通過binder跨進程調用A的添加圖書的方法,實現binder客戶端。
package com.shine.binderdemo;
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.support.v7.app.AppCompatActivity;
import android.view.View;
import android.widget.Toast;
public class MainActivity extends AppCompatActivity {
private IBookManager mBookManager;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Intent intent = new Intent(this, MyService.class);
bindService(intent, mServiceConnection, Context.BIND_AUTO_CREATE);
}
public void add(View view) {
Book book = new Book();
book.setBookId(1);
book.setBookName("《第一行代碼》");
try {
mBookManager.addBook(book);
} catch (RemoteException e) {
e.printStackTrace();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
unbindService(mServiceConnection);
}
private ServiceConnection mServiceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
mBookManager = IBookManager.Stub.asInterface(service);
try {
mBookManager.registerListener(mListener);
} catch (RemoteException e) {
e.printStackTrace();
}
}
@Override
public void onServiceDisconnected(ComponentName name) {
}
};
private IOnBookAddListener mListener = new IOnBookAddListener.Stub() {
@Override
public void onBookadd(final Book book) throws RemoteException {
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(MainActivity.this, book.toString(), Toast.LENGTH_SHORT).show();
}
});
}
};
}
首先來驗證服務端中mBinder 的addBook(Book book)方法確實是在一個子線程中進行的。
private IOnBookAddListener mListener = new IOnBookAddListener.Stub() {
@Override
public void onBookadd(final Book book) throws RemoteException {
//方法回調在子線程中
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(MainActivity.this, book.toString(), Toast.LENGTH_SHORT).show();
}
});
}
};
在onBookadd(final Book book) 回調中通過runOnUiThread(new Runnable())將線程切回主線程然后才能Toast提示。這也從側面證明了binder服務端處理客戶端的請求時會在一個子線程中處理。這里我們還有一個地方要注意,雖然binder服務會在一個子線程中處理客戶端的請求,但是客戶端請求時卻不會新開一個線程,從上面的代碼我們可能還看不出什么,如果將服務端的添加圖書的代碼設置為耗時操作,運行程序,點擊添加圖書可能就會出現ANR。(這里就不驗證了)所以在確定服務端處理請求時是耗時的操作的時候。有必要新開一個線程去請求。
上面說了這么多,總結一句話:binder服務在處理客戶端的請求時是在一個獨立的線程中完成的,而客戶端請求處理,不會新開一個線程,如果是耗時操作,則可能出現ANR。
四、Binder跨進程間原理
首先來看,在activity中點擊添加圖書會調用add(View view),然后調用mBookManager.addBook(book);就能成功的往MyService的bookList中添加一本書,這是為什么呢?我們的activity和service明明處于兩個進程,確好像在同一個進程中直接持有了服務端實現addBook(Book book)方法的mBinder 的引用,通過這個引用就調用了MyService 所在進程的方法。要解釋這個問題,我們自然而然的要分析activity中的mBookManager是怎么被賦值的:
private ServiceConnection mServiceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
//mBookManager 在這里賦值
mBookManager = IBookManager.Stub.asInterface(service);
try {
mBookManager.registerListener(mListener);
} catch (RemoteException e) {
e.printStackTrace();
}
}
@Override
public void onServiceDisconnected(ComponentName name) {
}
};
看到這里mBookManager = IBookManager.Stub.asInterface(service);我們可能會糊涂,這個IBookManager.Stub到底是個什么東西,為什么調用它的asInterface(service)方法就能得到mBinder 的引用?接下來我們就來分析IBookManager.Stub是個啥,它的asInterface(service)到底有什么奧秘。
package com.shine.binderdemo;
public interface IBookManager extends android.os.IInterface {
/**
* Local-side IPC implementation stub class.
*/
public static abstract class Stub extends android.os.Binder implements com.shine.binderdemo.IBookManager {
private static final java.lang.String DESCRIPTOR = "com.shine.binderdemo.IBookManager";
/**
* Construct the stub at attach it to the interface.
*/
public Stub() {
this.attachInterface(this, DESCRIPTOR);
}
/**
* Cast an IBinder object into an com.shine.binderdemo.IBookManager interface,
* generating a proxy if needed.
*/
public static com.shine.binderdemo.IBookManager asInterface(android.os.IBinder obj) {
if ((obj == null)) {
return null;
}
android.os.IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
if (((iin != null) && (iin instanceof com.shine.binderdemo.IBookManager))) {
return ((com.shine.binderdemo.IBookManager) iin);
}
return new com.shine.binderdemo.IBookManager.Stub.Proxy(obj);
}
@Override
public android.os.IBinder asBinder() {
return this;
}
@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_addBook: {
data.enforceInterface(DESCRIPTOR);
com.shine.binderdemo.Book _arg0;
if ((0 != data.readInt())) {
_arg0 = com.shine.binderdemo.Book.CREATOR.createFromParcel(data);
} else {
_arg0 = null;
}
this.addBook(_arg0);
reply.writeNoException();
return true;
}
case TRANSACTION_registerListener: {
data.enforceInterface(DESCRIPTOR);
com.shine.binderdemo.IOnBookAddListener _arg0;
_arg0 = com.shine.binderdemo.IOnBookAddListener.Stub.asInterface(data.readStrongBinder());
this.registerListener(_arg0);
reply.writeNoException();
return true;
}
}
return super.onTransact(code, data, reply, flags);
}
private static class Proxy implements com.shine.binderdemo.IBookManager {
private android.os.IBinder mRemote;
Proxy(android.os.IBinder remote) {
mRemote = remote;
}
@Override
public android.os.IBinder asBinder() {
return mRemote;
}
public java.lang.String getInterfaceDescriptor() {
return DESCRIPTOR;
}
@Override
public void addBook(com.shine.binderdemo.Book book) throws android.os.RemoteException {
android.os.Parcel _data = android.os.Parcel.obtain();
android.os.Parcel _reply = android.os.Parcel.obtain();
try {
_data.writeInterfaceToken(DESCRIPTOR);
if ((book != null)) {
_data.writeInt(1);
book.writeToParcel(_data, 0);
} else {
_data.writeInt(0);
}
mRemote.transact(Stub.TRANSACTION_addBook, _data, _reply, 0);
_reply.readException();
} finally {
_reply.recycle();
_data.recycle();
}
}
@Override
public void registerListener(com.shine.binderdemo.IOnBookAddListener listener) throws android.os.RemoteException {
android.os.Parcel _data = android.os.Parcel.obtain();
android.os.Parcel _reply = android.os.Parcel.obtain();
try {
_data.writeInterfaceToken(DESCRIPTOR);
_data.writeStrongBinder((((listener != null)) ? (listener.asBinder()) : (null)));
mRemote.transact(Stub.TRANSACTION_registerListener, _data, _reply, 0);
_reply.readException();
} finally {
_reply.recycle();
_data.recycle();
}
}
}
static final int TRANSACTION_addBook = (android.os.IBinder.FIRST_CALL_TRANSACTION + 0);
static final int TRANSACTION_registerListener = (android.os.IBinder.FIRST_CALL_TRANSACTION + 1);
}
public void addBook(com.shine.binderdemo.Book book) throws android.os.RemoteException;
public void registerListener(com.shine.binderdemo.IOnBookAddListener listener) throws android.os.RemoteException;
}
上面直接貼出了IBookManager.aidl編譯之后生成的IBookManager.java的代碼。這個代碼初看之下很長,不容易讀懂,下面我貼一張as里面關于這個類的結構圖。
[圖片上傳失敗...(image-7fc006-1570421770459)]
通過這個結構圖大概可以看出Stub是IBookManager的一個內部類,有一個asInterface(android.os.IBinder obj)方法,這也是上面activity中給mBookManager 賦值中用到的一個方法,我們待會再來分析。Stub內部又有一個內部類Proxy ,從名字上來看它應該是一個代理類,那么它到底代理的那個類呢?
public static com.shine.binderdemo.IBookManager asInterface(android.os.IBinder obj) {
if ((obj == null)) {
return null;
}
android.os.IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
if (((iin != null) && (iin instanceof com.shine.binderdemo.IBookManager))) {
return ((com.shine.binderdemo.IBookManager) iin);
}
//將IBinder類型的obj通過構造方法傳入Proxy
return new com.shine.binderdemo.IBookManager.Stub.Proxy(obj);
}
Proxy(android.os.IBinder remote) {
mRemote = remote;
}
看完上面兩段代碼應該可以看出Proxy代理的就是一個IBinder類型的obj,到這里我們明白了mBookManager原來就是一個Proxy代理類。熟悉代理模式的話,我們知道代理類只是持有一個真實類的引用,真正功能都是由這個真實類實現的。在這個IBookManager.Stub.Proxy里面,真實類是什么呢?
private static class Proxy implements com.shine.binderdemo.IBookManager {
//mRemote是這個代理中的真實對象
private android.os.IBinder mRemote;
Proxy(android.os.IBinder remote) {
mRemote = remote;
}
......
@Override
public void addBook(com.shine.binderdemo.Book book) throws android.os.RemoteException {
android.os.Parcel _data = android.os.Parcel.obtain();
android.os.Parcel _reply = android.os.Parcel.obtain();
try {
_data.writeInterfaceToken(DESCRIPTOR);
if ((book != null)) {
_data.writeInt(1);
book.writeToParcel(_data, 0);
} else {
_data.writeInt(0);
}
//mBookManager的addBook(Book book)方法實際上是調用 mRemote.transact(...)方法
mRemote.transact(Stub.TRANSACTION_addBook, _data, _reply, 0);
_reply.readException();
} finally {
_reply.recycle();
_data.recycle();
}
}
......
}
這段代碼的注釋已經很明確的IBinder類型的mRemote是這個類的真實引用。
mBookManager.addBook(Book book)方法最后會調用mRemote.transact(...)方法,那么這個mRemote是個啥呢?mRemote是如何傳入Proxy呢?
我們在之前的binder機制的概念時說過,binder機制涉及到4個組件,binder server ,binder client,binder內核驅動,Service Manager。在上面的情景中我們只分析了binder server 和binder client,接下來binder內核驅動(對于binder驅動,在下面只會提到它的作用,不涉及具體的代碼,具體的代碼分析我也不懂)就要出場了,而對于Service Manager,這個例子中卻不會直接涉及,在activity的啟動過程中會出現,到時候再分析,敬請期待。。。
有了binder驅動介入,就可以解決mRemote到底是個啥了。先看下MyService的onBinder(...)方法
@Override
public IBinder onBind(Intent intent) {
return mBinder;
}
private IBinder mBinder = new IBookManager.Stub() {
@Override
public void addBook(Book book) throws RemoteException {
booklist.add(book);
if (mListener != null) {
mListener.onBookadd(book);
}
}
@Override
public void registerListener(IOnBookAddListener listener) throws RemoteException {
mListener = listener;
}
};
這個mBinder是一個IBookManager.Stub()類型的變量,而IBookManager.Stub()繼承Binder,所以mBinder是一個Binder類型的對象。這個binder類型的對象實際上就是binder服務端,在binder服務端開啟的時候,同時會在binder內核建一個mRemote的binder對象,我們在上面提到的mRemote其實就是binder內核里面的mRemote binder對象。實際在binder進程間調用的時候必須要考慮的問題就是如何獲取binder內核mRemote 對象。這個例子采用的是Service做binder服務端,而binderService中google的工程師已經為我們實現好了。在ServiceConnection里面有個回調方法可以獲取binder內核的mRemote,如下:
private ServiceConnection mServiceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
//通過獲取到的service(mRemote)生成IBookManager.Stub.Proxy對象
mBookManager = IBookManager.Stub.asInterface(service);
try {
mBookManager.registerListener(mListener);
} catch (RemoteException e) {
e.printStackTrace();
}
}
@Override
public void onServiceDisconnected(ComponentName name) {
}
};
這樣我們就獲取到了binder內核的mRemote對象,同時也傳入了IBookManager.Stub.Proxy,接著就可以使用mRemote來進行跨線程的調用了。
接下來看下mBookManager到底是怎樣實現addBook(Book,book)方法的,上面已經分析了會調用到IBookManager.Stub.Proxy.addBook(Book book)。
@Override
public void addBook(com.shine.binderdemo.Book book) throws android.os.RemoteException {
//_data表示發送binder服務端的數據,這些數據需要通過Parcel (包裹)進行傳遞
android.os.Parcel _data = android.os.Parcel.obtain();
//_reply 表示binder服務端相應的數據,這些數據同樣需要通過Parcel (包裹)進行傳遞
android.os.Parcel _reply = android.os.Parcel.obtain();
try {
//用來做安全檢查,binder服務端會處理這個數據
_data.writeInterfaceToken(DESCRIPTOR);
if ((book != null)) {
_data.writeInt(1);
//Parcel (包裹)不僅可以傳遞基本類型的數據還可以傳遞對象,但是對象必須實現Parcelable接口
book.writeToParcel(_data, 0);
} else {
_data.writeInt(0);
}
//調用binder內核的mRemote對象往binder服務端發送信息
mRemote.transact(Stub.TRANSACTION_addBook, _data, _reply, 0);
_reply.readException();
} finally {
_reply.recycle();
_data.recycle();
}
}
代碼中注釋已經很詳細了,就不解釋了,接著看下面,我們說過mRemote實際上是一個binder類型的對象, mRemote.transact(Stub.TRANSACTION_addBook, _data, _reply, 0);就會調用到Binder的transact(...)方法中
public final boolean transact(int code, Parcel data, Parcel reply,
int flags) throws RemoteException {
if (false) Log.v("Binder", "Transact: " + code + " to " + this);
if (data != null) {
data.setDataPosition(0);
}
//調用binder服務端的onTransact(...)中
boolean r = onTransact(code, data, reply, flags);
if (reply != null) {
reply.setDataPosition(0);
}
return r;
}
其中transact(...)有四個參數,分別是 code,data,reply,flag。
code:整形的一個識別碼,客戶端傳入,用于區分服務端執行哪個方法。
data:客戶端傳入的Parcel類型的參數。
reply:服務端返回的結果值,也是Parcel類型數據。
flag:整形的一個標記,用于標記是否是否有返回值,0表示有返回值,1表示沒有。
接著再次涉及到binder內核驅動,具體的細節我也不太懂,直接的結論是流程會進入到binder服務端的IBookManager.Stub的onTransact(...)中:
@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;
}
//根據transact第一個參數來確定請求的是哪個方法
case TRANSACTION_addBook: {
data.enforceInterface(DESCRIPTOR);
com.shine.binderdemo.Book _arg0;
if ((0 != data.readInt())) {//如果客戶端傳遞是有非基本類型的數據從data中取出
_arg0 = com.shine.binderdemo.Book.CREATOR.createFromParcel(data);
} else {
_arg0 = null;
}
//取出來的數據傳到MyService的mBinder的addBook(...)
this.addBook(_arg0);
//由于addBook(...)沒有返回值,所以不需要通過reply返回結果
reply.writeNoException();
return true;
}
case TRANSACTION_registerListener: {
data.enforceInterface(DESCRIPTOR);
com.shine.binderdemo.IOnBookAddListener _arg0;
_arg0 = com.shine.binderdemo.IOnBookAddListener.Stub.asInterface(data.readStrongBinder());
this.registerListener(_arg0);
reply.writeNoException();
return true;
}
}
return super.onTransact(code, data, reply, flags);
}
通過上面從activity的addBool(Book book)開始,我們一步一步的分析了binder客戶端如何調用binder服務端的方法,這個過程在binder內核驅動的基礎上實現,讓我們感覺好像是調用本地(同一個進程)方法一樣,實質上底層為我們做了大量的工作。這樣基于aidl實現的Binder跨進程調用就大概談完了,對binder跨進程調用也應該有了一定的了解。
#######binder小結
跨進程調用的關鍵點在于如何獲得服務端的binder對象在內核里面的引用(如上面分析的mRemote)。
一般來說有兩種途徑,其一是通過Service Manager,這篇文章沒有直接涉及Service Manager,但是在底層源碼里面這種情況很常見,在Activity啟動過程中用到的ActivityManagerService就是通過這種方式在客戶端得到服務端的binder對象在內核里面的引用,我們以后再分析。其二是通過已經建立好的binder連接來獲取這個引用。如上面的例子中用到的一樣。
private IOnBookAddListener mListener = new IOnBookAddListener.Stub() {
@Override
public void onBookadd(final Book book) throws RemoteException {
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(MainActivity.this, book.toString(), Toast.LENGTH_SHORT).show();
}
});
}
};
這是一個實現了接口方法的Binder服務,通過已經建立好的binde連接mBookManager傳遞給Myservice所在進程。
private ServiceConnection mServiceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
mBookManager = IBookManager.Stub.asInterface(service);
try {
//通過已經建立好的連接傳送服務端binder的引用
mBookManager.registerListener(mListener);
} catch (RemoteException e) {
e.printStackTrace();
}
}
@Override
public void onServiceDisconnected(ComponentName name) {
}
};
有一點需要注意,在此時activity所在的進程就成為了我們通常所說的binder服務端,而MyService則是binder客戶端。這就是第二種獲取binder服務引用的方式。再多談一點,其實在這個例子中,兩個建立的binder連接都是通過已將建立好的連接來傳遞的,除了mListener 這個binder引用的獲得,mBinder也是這種情況。這里就不再詳細討論了,如果感興趣可以學習一下bindService的源碼,就肯定能發現這一點。最后貼上一張例子用到的uml類圖:
[圖片上傳失敗...(image-6d3652-1570421770459)]