Inter-process communication-Binder

Binder framework overview

Binder is an architecture that provides three modules: server interface, Binder driver, and client interface, as shown in the figure.
Insert image description here

Server

Let’s look at the server first. A Binder server is actually an object of the Binder class. Once the object is created, a hidden thread is started internally .

The thread will then receive the message sent by the Binder driver . After receiving the message, it will execute the onTransact() function in the Binder object and execute different service functions according to the parameters of the function . Therefore, to implement a Binder service, you must overload the onTransact() method.

The main content of the overloaded onTransact()function is to onTransact()convert the parameters of the function into the parameters of the service function. The source of the parameters of the function is input onTransact()by the client when calling the function. Therefore, if there is a fixed format input, then there will be a fixed format output.transact()transact()onTransact()

Binder driver

When any server-side Binder object is created, an mRemote object will also be created in the Binder driver. The type of this object is also the Binder class. When the client wants to access remote services, it always uses the mRemote object.

client

If the client wants to access the remote service, it must obtain the mRemote reference corresponding to the remote service in the Binder driver. After obtaining the mRemote object, you can call its transact() method. In the Binder driver, the mRemote object also overloads the transact() method. The overloaded content mainly includes the following items.

  • In the inter-thread message communication mode, the parameters passed by the client are sent to the server.

  • Suspend the current thread, which is the client thread, and wait for notification after the server thread finishes executing the specified service function.

  • After receiving the notification from the server thread, continue executing the client thread and return to the client code area.

It can be seen from here that to application developers, the client seems to directly call the Binder corresponding to the remote service, but in fact it is transferred through the Binder driver. That is, there are two Binder objects, one is the Binder object on the server side, and the other is the Binder object in the Binder driver. The difference is that the object in the Binder driver will not generate an additional thread.

How does the client obtain the corresponding mRemote reference in the Binder driver?
How does the Binder driver send messages to the server?

Design server and client

Design server

The server is a Binder class object. Just create a new Server class based on the Binder class. The following takes designing a MusicPlayerService class as an example.

Assuming that the Service only provides two methods: start(String filePath) and stop(), then the code of this class can be as follows:

public class MusicPlayerService extends Binder {
    @Override
    protected boolean onTransact(int code, Parcel data, Parcel reply, int flags) throws RemoteException {
     	switch (code) {
            case 1000:
                data.enforceInterface("MusicPlayerService");
                String filePath = data.readString();
                start(filePath);
                // replay.writeXXX();
                break;
        }
    	return super.onTransact(code, data, reply, flags);
    }
 
    public void start(String filePath) {
 
    }
 
    public void stop() {
        
    }
}

The code variable is agreed between the client and the server and is used to identify which function on the server the client expects to call.

It is assumed here that 1000 is the value agreed by both parties to call the start() function.

enforceInterface() is for some kind of verification, which corresponds to the client's writeInterfaceToken().

readString() is used to retrieve a string from the package. After taking out the filePath variable, you can call the start() function on the server side.

If the client expects the server to return some results, it can call the related functions provided by Parcel in the return package reply to write the corresponding results.

When you want to start the service, you only need to initialize a MusicPlayerService object. For example, you can initialize a MusicPlayerService in the main Activity and then run it. At this time, you can find that one more thread, Binder-Thread3, is running.

If MusicPlayerService is not created, there are only 2 threads corresponding to Binder objects. Namely Binder-Thread1 and Binder-Thread2.

Where did these two processes come from?

client design

To use the server, you must first obtain a reference to the mRemote variable corresponding to the server in the Binder driver. Once you have a reference to the variable, you can call the variable's transact() method. The function prototype of this method is as follows:

public final boolean transact(int code, Parcel data, Parcel reply,int flags) 

Among them, data represents the package (Parcel) to be passed to the remote Binder service. The parameters required by the remote service function must be put into this package. Only variables of specific types can be placed in the package. These types include commonly used atomic types, such as String, int, long, etc. In addition to general atomic variables, Parcel also provides a writeParcel() method that can include a small parcel in the parcel. Therefore, when calling Binder remote service, the parameter of the service function must be either an atomic class or must inherit from the Parcel class , otherwise, it cannot be passed.

Therefore, for the client of MusicPlayerService, the transact() method can be called as follows.

        IBinder mRemote = null;
        String filePath = "/sdcard/music/heal_the_world.mp3";
        int code = 1000;
        Parcel data = Parcel.obtain();
        Parcel reply = Parcel.obtain();
        data.writeInterfaceToken("MusicPlayerService");
        data.writeString(filePath);
        mRemote.transact(code, data, reply, 0);
        IBinder binder = reply.readStrongBinder();
        reply.recycle();
        data.recycle();

First of all, the package is not created by the client itself, but applied for by calling Parcel.obtain().
Both data and reply variables are provided by the client, and the reply variable is put by the server into the returned results.

writeInterfaceToken() method marks the name of the remote service. Theoretically, this name is not necessary because since the client has obtained the Binder reference of the specified remote service, it will not call other remote services. This name will be used by the Binder driver to ensure that the client actually wants to call the specified server.

writeString() method is used to add a String variable to the package. onTransactNote that the content added in the package is in order. This order must be agreed upon by the client and the server in advance. The variables will be taken out in the () method of the server in the agreed order .

Then call the transact() method. After calling this method,

When the client thread enters the Binder driver, the Binder driver will suspend the current thread and send a message to the remote service, which contains the package passed in by the client. After the server gets the package, it will disassemble the package, and then execute the specified service function. After the execution is completed, the execution result will be put into the reply package provided by the client. Then the server sends a notify message to the Binder driver, causing the client thread to return from the Binder driver code area to the client code area.

The meaning of the last parameter of transact() is the mode of executing IPC calls, which is divided into two types: one is bidirectional, represented by the constant 0, which means that the server will return certain data after executing the specified service; the other It is one-way, represented by the constant 1, which means no data is returned.

Finally, the client can parse the returned data from the reply. Similarly, the data contained in the return package must also be in order, and this order must be agreed upon by the server and the client in advance.

Binder and Service

There are two important problems in the above process of manually writing the Binder server and client.
First, how does the client obtain the Binder object reference of the server.

Second, the client and server must agree on two things in advance:

  • The order of the parameters of the server function in the package.

  • Int type identifiers of different functions on the server side. That is the value of the code parameter in the transact method.

Service

So, how does the Service class solve the two important issues raised at the beginning of this section?

First of all, AmS provides the startService() function to start the customer service. For the client, you can use the following two functions to establish a connection with a service, and its prototype is in the android.app. ContextImpl class.

    @Override
    public ComponentName startService(Intent service) {
        try {
            ComponentName cn = ActivityManagerNative.getDefault().startService(
                mMainThread.getApplicationThread(), service,
                service.resolveTypeIfNeeded(getContentResolver()));
            if (cn != null && cn.getPackageName().equals("!")) {
                throw new SecurityException(
                        "Not allowed to start service " + service
                        + " without permission " + cn.getClassName());
            }
            return cn;
        } catch (RemoteException e) {
            return null;
        }
    }

This function is used to start intentthe specified service. After startup, the client does not yet have a Binder reference from the server, so it cannot call any service functions yet.

    @Override
    public boolean bindService(Intent service, ServiceConnection conn,
            int flags) {
        IServiceConnection sd;
        if (mPackageInfo != null) {
            sd = mPackageInfo.getServiceDispatcher(conn, getOuterContext(),
                    mMainThread.getHandler(), flags);
        } else {
            throw new RuntimeException("Not supported in system context");
        }
        try {
            int res = ActivityManagerNative.getDefault().bindService(
                mMainThread.getApplicationThread(), getActivityToken(),
                service, service.resolveTypeIfNeeded(getContentResolver()),
                sd, flags);
            if (res < 0) {
                throw new SecurityException(
                        "Not allowed to bind to service " + service);
            }
            return res != 0;
        } catch (RemoteException e) {
            return false;
        }
    }

This function is used to bind a service, which is the key to the first important question. The second parameter is an interface class, and the definition of the interface is as shown in the following code:

/**
 * Interface for monitoring the state of an application service.  See
 * {@link android.app.Service} and
 * {@link Context#bindService Context.bindService()} for more information.
 * <p>Like many callbacks from the system, the methods on this class are called
 * from the main thread of your process.
 */
public interface ServiceConnection {
    /**
     * Called when a connection to the Service has been established, with
     * the {@link android.os.IBinder} of the communication channel to the
     * Service.
     *
     * @param name The concrete component name of the service that has
     * been connected.
     *
     * @param service The IBinder of the Service's communication channel,
     * which you can now make calls on.
     */
    public void onServiceConnected(ComponentName name, IBinder service);
    public void onServiceDisconnected(ComponentName name);
}

Please note the second variable Service in the onServiceConnected() method in this interface. When the client requests AmS to start a Service, if the Service starts normally, AmS will remotely call the ApplicationThread object in the ActivityThread class. The parameters of the call will contain the Binder reference of the Service, and then the ApplicationThread will call back the bindService. conn interface. Therefore, in the client, its parameter Service can be saved as a global variable in the onServiceConnected() method, so that the remote service can be called at any time anywhere on the client. This solves the first important problem, which is how the client obtains the Binder reference of the remote service.

Insert image description here

AIDL guarantees the order of parameters within the package

Regarding the second question, the Android SDK provides an aidl tool, which can convert an aidl file into a Java class file. In the Java class file, the transact and onTransact() methods are overloaded at the same time, unifying the storage Entering packages and reading package parameters allows the designer to focus on the service code itself.

Next, let’s see what the aidl tool does. As shown in the example in the first section of this chapter, it is still assumed here that a MusicPlayerService service is to be written. The service contains two service functions, namely start() and stop(). Then, you can first write an IMusicPlayerService.aidl file. As shown in the following code:

    package com.haiii.android.client;  
    interface IMusicPlayerService{  
        boolean start(String filePath);  
        void stop();  
    }  

The name of the file must follow certain specifications. The first letter "I" is not required. However, for the sake of unification of program style, the meaning of "I" is the IInterface class, that is, this is a class that can provide access to remote services. The subsequent naming – MusicPlayerService corresponds to the class name of the service, which can be arbitrary, but the aidl tool will name the output Java class with this name.

The syntax of the aidl file is basically similar to Java. package specifies the package name corresponding to the output Java file . If the file needs to reference other Java classes, you can use the import keyword, but it should be noted that only the following three types of content can be written in the package:

  • Java atomic types, such as int, long, String and other variables.
  • Binder reference.
  • Object that implements Parcelable.

Therefore, basically speaking, the Java classes referenced by import can only be the above three types.

Interface is a keyword. Sometimes a oneway is added in front of interface, which means that the methods provided by the service have no return value, that is, they are all void type.

Let's take a look at the code of the IMusicPlayerService.java file generated by aidl. As follows:

package com.haiii.client;
 
public interface IMusicPlayerService extends android.os.IInterface {
    /**
     * Local-side IPC implementation stub class.
     */
    public static abstract class Stub extends android.os.Binder
            implements com.haiii.client.IMusicPlayerService {
        private static final java.lang.String DESCRIPTOR =
                "com.haiii.client.IMusicPlayerService";
 
        /**
         * Construct the stub at attach it to the interface.
         */
        public Stub() {
            this.attachInterface(this, DESCRIPTOR);
        }
 
        /**
         * Cast an IBinder object into an com.haiii.client.IMusicPlayerService interface,
         * generating a proxy if needed.
         */
        public static com.haiii.client.IMusicPlayerService
        asInterface(android.os.IBinder obj) {
            if ((obj == null)) {
                return null;
            }
 
            android.os.IInterface iin =
                    (android.os.IInterface) obj.queryLocalInterface(DESCRIPTOR);
 
            if (((iin != null) && (iin instanceof com.haiii.client.IMusicPlayerService))) {
                return ((com.haiii.client.IMusicPlayerService) iin);
            }
            return new com.haiii.client.IMusicPlayerService.Stub.Proxy(obj);
        }
 
        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_start: {
                    data.enforceInterface(DESCRIPTOR);
                    java.lang.String _arg0;
                    _arg0 = data.readString();
                    boolean _result = this.start(_arg0);
                    reply.writeNoException();
                    reply.writeInt(((_result) ? (1) : (0)));
                    return true;
                }
                case TRANSACTION_stop: {
                    data.enforceInterface(DESCRIPTOR);
                    this.stop();
                    reply.writeNoException();
                    return true;
                }
            }
            return super.onTransact(code, data, reply, flags);
        }
 
        private static class Proxy implements com.haiii.client.IMusicPlayerService {
            private android.os.IBinder mRemote;
 
            Proxy(android.os.IBinder remote) {
                mRemote = remote;
            }
 
            public android.os.IBinder asBinder() {
                return mRemote;
            }
 
            public java.lang.String getInterfaceDescriptor() {
                return DESCRIPTOR;
            }
 
            public boolean start(java.lang.String filePath) throws android.os.RemoteException {
                android.os.Parcel _data = android.os.Parcel.obtain();
                android.os.Parcel _reply = android.os.Parcel.obtain();
                boolean _result;
                try {
                    _data.writeInterfaceToken(DESCRIPTOR);
                    _data.writeString(filePath);
                    mRemote.transact(Stub.TRANSACTION_start, _data, _reply, 0);
                    _reply.readException();
                    _result = (0 != _reply.readInt());
                } finally {
                    _reply.recycle();
                    _data.recycle();
                }
                return _result;
            }
 
            public void stop() throws android.os.RemoteException {
                android.os.Parcel _data = android.os.Parcel.obtain();
                android.os.Parcel _reply = android.os.Parcel.obtain();
                try {
                    _data.writeInterfaceToken(DESCRIPTOR);
                    mRemote.transact(Stub.TRANSACTION_stop, _data, _reply, 0);
                    _reply.readException();
                } finally {
                    _reply.recycle();
                    _data.recycle();
                }
            }
        }
 
        static final int TRANSACTION_start = (android.os.IBinder.FIRST_CALL_TRANSACTION + 0);
        static final int TRANSACTION_stop = (android.os.IBinder.FIRST_CALL_TRANSACTION + 1);
    }
 
    public boolean start(java.lang.String filePath) throws android.os.RemoteException;
 
    public void stop() throws android.os.RemoteException;
}  

These codes mainly accomplish the following three tasks.

IMusicPlayerService

Define one Java interface, which contains the service function declared in the aidl file. The class name is IMusicPlayerService, and the class is based on IInterfacethe interface, that is, a asBinder() function needs to be provided.

Proxy

Define a Proxy class, which will serve as a proxy for client programs to access the server. The so-called proxy is mainly for the second important issue mentioned earlier - unifying the order of writing parameters in the package.

Stub

Define a Stub class, which is an abstract class based on the Binder class and implements IMusicPlayerServicethe interface, mainly used by the server. The reason why this class is defined as an abstract class is because the specific service functions must be implemented by programmers. Therefore, IMusicPlayerServicethe functions defined in the interface do not need to be specifically implemented in the Stub class. At the same time, the () method is overloaded in the Stub class onTransact. Since transactthe order of writing parameters into the package inside the () method is defined by the aidl tool, in the onTransact() method, the aidl tool naturally knows what order it should be in. Get the corresponding parameters from the package.

Some int constants are also defined in the Stub class, such as TRANSACTION_start. These constants correspond to the service functions. The values ​​of the first parameter code of the transact() and onTransact() methods come from this.

In the Stub class, in addition to the tasks mentioned above, Stub also provides an asInterface() function. What this function does is provided as follows:

The reason for providing this function is that in addition to other processes, the services provided by the server can also be used by other classes within the service process. For the latter, it obviously does not need to be called through IPC, but can be called directly within the process. , and there is a queryLocalInterface (String description) function inside Binder, which determines whether the Binder object is a local Binder reference based on the input string.

Insert image description here
When creating a service, a Binder object is created inside the server process, and a Binder object is also created in the Binder driver. If the Binder of the server process is obtained from the client process, only the Binder object in the Binder driver will be returned. If the Binder object is obtained from within the server process, the Binder object of the server itself will be obtained.

Therefore, the asInterface() function uses the queryLocalInterface() method to provide a unified interface. Whether it is a remote client or an internal process of the server, after obtaining the Binder object, you can use the obtained Binder object as a parameter of asInterface() to return an IMusicPlayerService interface, which either uses the Proxy class or directly uses the one implemented by Stub. Corresponding service function.

Binder object in system service

In applications, getSystemService(String serviceName)methods are often used to obtain a system service. So, how are the Binder references of these system services passed to the client?

It should be noted that system services are not startService()started by . getSystemService()The function is implemented in the ContextImpl class. This function returns many services. Please refer to the source code for details. These Services are generally managed by ServiceManager.

Services managed by ServiceManger

ServiceManager is an independent process. Its function is as shown in the name. It manages various system services. The management logic is as follows:
Insert image description here
ServiceManager itself is also a Service. The Framework provides a system function to obtain the Binder reference corresponding to the Service, that isBinderInternal.getContextObject()。

After the static function returns ServiceManager, you can obtain the Binder reference of other system services through the methods provided by ServiceManager.

When other system services are started, they first pass their Binder objects to ServiceManager, which is the so-called registration (addService).

Check below to obtain a Service{IMPUT_METHOD_SERVICE}:

if (INPUT_METHOD_SERVICE.equals(name)) {
            return InputMethodManager.getInstance(this);
static public InputMethodManager getInstance(Looper mainLooper) {
        synchronized (mInstanceSync) {
            if (mInstance != null) {
                return mInstance;
            }
            IBinder b = ServiceManager.getService(Context.INPUT_METHOD_SERVICE);
            IInputMethodManager service = IInputMethodManager.Stub.asInterface(b);
            mInstance = new InputMethodManager(service, mainLooper);
        }
        return mInstance;
    }

That is, by ServiceManagerobtaining InputMethod Servicethe corresponding Binder object b, and then using the Binder object as IInputMethodManager.Stub.asInterface()a parameter, a IInputMethodManagerunified interface is returned.

ServiceManager.getService()The code is as follows:

public static IBinder getService(String name) {
        try {
            IBinder service = sCache.get(name);
            if (service != null) {
                return service;
            } else {
                return getIServiceManager().getService(name);
            }
        } catch (RemoteException e) {
            Log.e(TAG, "error in getService", e);
        }
        return null;
    }

That is, first sCachecheck whether there is a corresponding Binder object from the cache. If there is, it will be returned. If not, it will be called getIServiceManager().getService(name). The function getIServiceManager()is used to return the only ServiceManagercorresponding Binder in the system. The code is as follows:

private static IServiceManager getIServiceManager() {
        if (sServiceManager != null) {
            return sServiceManager;
        }
        // Find the service manager
        sServiceManager = ServiceManagerNative.asInterface(BinderInternal.getContextObject());
        return sServiceManager;
    }

BinderInternal.getContextObject()The static function is used to return the global Binder object corresponding to ServiceManager. This function does not require any parameters because its role is fixed. The process of all other system services obtained through ServiceManager is basically similar to the above. The only difference is that the service name passed to ServiceManager is different, because ServiceManager saves different Binder objects according to the name of the service (String type).

Adding a service to ServiceManager using addService() is generally completed when the SystemService process starts.

Understanding Manger

All services managed by ServiceManager are returned to the client with the corresponding Manager. Therefore, here is a brief description of the semantics of Manager in the Framework.

In Android, the meaning of Manager should be translated as broker. The object managed by Manager is the service itself, because each specific service generally provides multiple API interfaces, and it is these APIs that Manager manages.

The client generally cannot access specific services directly through Binder references. It needs to first obtain the Binder reference of the remote service through ServiceManager, and then use this Binder reference to construct a broker that the client can access locally, such as the previous IInputMethodManager, and then the client You can access remote services through this broker.

The model diagram for accessing remote services through local Manger is as follows:
Insert image description here

  1. The new interface design will bring a new writing experience;
  2. Set your favorite code highlighting style in the creation center, and Markdown will display the code piece in the selected highlighting style ;
  3. Added image drag and drop function, you can drag local images directly to the editing area for direct display;
  4. Brand new KaTeX mathematical formula syntax;
  5. Added mermaid syntax 1 function that supports Gantt charts ;
  6. Added the function of editing Markdown articles on multiple screens;
  7. Added functions such as focus writing mode, preview mode, concise writing mode, left and right area synchronized wheel settings, etc. The function button is located between the editing area and the preview area;
  8. Checklist functionality added .

Function shortcut keys

Undo: Ctrl/Command+ Z
Redo: Ctrl/Command+ Y
Bold: Ctrl/Command+ B
Italic: Ctrl/Command+ I
Title: Ctrl/Command+ Shift+ H
Unordered list: Ctrl/Command+ Shift+ U
Ordered list: Ctrl/Command+ Shift+ O
Checklist: + +Insert code : + Ctrl/Command+ Insert link : + + Insert image: + + Find: + Replace: +ShiftC
Ctrl/CommandShiftK
Ctrl/CommandShiftL
Ctrl/CommandShiftG
Ctrl/CommandF
Ctrl/CommandG

Properly create titles to help create a table of contents

Enter it directly once #and press it, spaceand a level 1 title will be generated.
After entering it twice #and pressing it space, a level 2 title will be generated.
By analogy, we support level 6 titles. TOCHelps to generate a perfect table of contents using syntax.

How to change the style of text

Emphasis on text Emphasis on text

bold text bold text

mark text

Delete text

quoted text

H 2 O is a liquid.

The result of 2 10 operation is 1024.

Insert links and images

Link: link .

picture:Alt

Pictures with dimensions:Alt

Centered image:Alt

Centered and sized image:Alt

Of course, in order to make it more convenient for users, we have added the image drag and drop function.

How to insert a beautiful piece of code

Go to the blog settings page and choose a code fragment highlighting style you like. The same highlighting style is shown below 代码片.

// An highlighted block
var foo = 'bar';

Generate a list that works for you

  • project
    • project
      • project
  1. Project 1
  2. Project 2
  3. Project 3
  • Scheduled Tasks
  • mission accomplished

Create a form

A simple table is created like this:

project Value
computer $1600
cell phone $12
catheter $1

Set content to center, left, or right

Use :---------:center
. Use :----------left.
Use ----------:right.

first row The second column third column
The first column of text is centered The second column of text is on the right The third column of text is on the left

SmartyPants

SmartyPants converts ASCII punctuation characters into "smart" typographic punctuation HTML entities. For example:

TYPE ASCII HTML
Single backticks 'Isn't this fun?' ‘Isn’t this fun?’
Quotes "Isn't this fun?" “Isn’t this fun?”
Dashes -- is en-dash, --- is em-dash – is en-dash, — is em-dash

Create a custom list

Markdown
Text-to- HTML conversion tool
Authors
John
Luke

How to create a footnote

A text with footnotes. 2

Comments are also essential

Markdown converts text to HTML .

KaTeX math formula

You can render LaTeX mathematical expressions using KaTeX :

Gamma infrastructure ( n ) = ( n − 1 ) ! ∀ n ∈ N \Gamma(n) = (n-1)!\quad\forall n\in\mathbb NC ( n )=(n1)!nN is the integral via Euler

Γ ( z ) = ∫ 0 ∞ t z − 1 e − t d t   . \Gamma(z) = \int_0^\infty t^{z-1}e^{-t}dt\,. C ( z )=0tz 1 etdt.

You can find more information about LaTeX mathematical expressions here .

New Gantt chart function to enrich your articles

2014-01-07 2014-01-09 2014-01-11 2014-01-13 2014-01-15 2014-01-17 2014-01-19 2014-01-21 已完成 进行中 计划一 计划二 现有任务 Adding GANTT diagram functionality to mermaid
  • Regarding Gantt chart syntax, please refer here ,

UML diagram

UML diagrams can be used for rendering. Mermaid . For example, a sequence diagram generated below:

张三 李四 王五 你好!李四, 最近怎么样? 你最近怎么样,王五? 我很好,谢谢! 我很好,谢谢! 李四想了很长时间, 文字太长了 不适合放在一行. 打量着王五... 很好... 王五, 你怎么样? 张三 李四 王五

This will produce a flowchart. :

链接
长方形
圆角长方形
菱形
  • Regarding Mermaid syntax, refer here ,

FLowchart flowchart

We will still support flowchart flow charts:

Created with Raphaël 2.3.0 开始 我的操作 确认? 结束 yes no
  • Regarding Flowchart flowchart syntax, refer here .

Export and import

Export

If you want to try using this editor, you can edit whatever you want in this article. When you finish writing an article, find the article export in the upper toolbar and generate a .md file or .html file for local saving.

import

If you want to load a .md file you have written, you can select the import function on the upper toolbar to import the file with the corresponding extension and
continue your creation.


  1. mermaid syntax description↩︎

  2. Explanation of footnote↩︎

Guess you like

Origin blog.csdn.net/jxq1994/article/details/132609102