Android zygote访谈录

戳蓝字“牛晓伟”关注我哦!

用心坚持输出易读、有趣、有深度、高质量、体系化的技术文章,技术文章也可以有温度。

本文摘要

本文以访谈的方式来带大家了解zygote进程,了解zygote进程是啥?它的作用是啥?它是如何一步一步“长大成人”的。(文中代码基于Android13)

Android native系列的文章如下:
Android系统native进程之我是init进程
Android系统native进程之属性能力的设计魅力
Android系统native进程之进程杀手–lmkd
Android系统native进程之日志系统–logd、logcat
Android系统native进程之我是installd进程
Apk安装之谜
Android 存储成长记
Android vold(卷管理)
Android ServiceManager和它的兄弟们

Android 大话binder通信

鼎鼎大名的zygote

主持人:“大家好啊,我是今天的主持人,你们大伙儿可是赚到,为啥因为我今天有请到了鼎鼎大名的zygote,千万别和我说你不认识她,在Android所有的系统native进程中,她的名气已经完全超过了vold、installd、lmkd等兄弟们,甚至连她的父亲init都自愧不如。”

一位观众提问到:“不好意思主持人,我确实也听说过她,但都是从别人嘴里面得知的,完全不知道zygote名气大的原因是啥?”

“谢谢这位观众,这确实是我的疏忽,首先我认为zygote名气大的原因是她有很多的子进程,而这些子进程是可以直接跟用户打交道的比如微信、抖音,而像init它的很多直接子进程都是demon类型的,只是在后台默默无闻的工作,用户对他们完全没感知。其次她是所有系统native进程中唯一可以运行Java/Kotlin代码的进程。那就有请我们今天的主角zygote吧。”

zygote:“大家好,主持人完全过奖了,我可不敢当。我的真名是zygote64是一个系统native进程。我其实还有个妹妹她的真名字是zygote,她也和我一样也是一个系统native进程。我和我妹妹的主要工作职责是fork (孵化)可运行java代码的进程,我和她分工明确我是孵化64位进程,而她是孵化32位进程,这也就是为啥我的真名后面有64的原因。别看我的工作职责很单一,可是有非常多的重量级的进程如systemserver进程可都是我孵化出来的,而我妹妹是没有孵化systemserver进程的。”

又一位观众提问到:“您好zygote,我这有问题请教您提到的fork是啥意思?”

zygote:“fork是一个孵化子进程的方法,该方法的一个非常突出的特点就是,能以快到惊人的速度把子进程创建好,fork做到如此之快的原因是会创建一个与父进程几乎完全相同的副本,子进程从父进程继承了所有的内存布局、环境变量、打开的文件描述符等,也就是子进程会与父进程共享非常多的数据,在内核层只需要为子进程创建很少的数据即可。还有一个概念写时复制 (Copy-on-Write),子进程与父进程共享的数据,比如子进程或者父进程改变了一些数据的话,则这些数据不会再是共享状态了,而是会拷贝出一份来,这就是写时复制。”

这位观众继续提问到:“听了您的解释还是有些懵圈,能具体点吗?”

zygote:“那这样吧,我们来看段代码,看了代码您肯定就明白了。”

#include <stdio.h>  
#include <stdlib.h>  
#include <unistd.h>  

int globvar = 6;
char globbuf[] = "我是全局字符串";

int main() {  
    pid_t pid;  
    
    int localvar = 88;
  
    // 调用fork()  
    pid = fork();  
  
    //小于0,则代表fork失败
    if (pid < 0) {  
        fprintf(stderr, "Fork failed\n");  
        return 1;  
    } else if (pid == 0) {  
        //pid == 0,则代表是fork成功的子进程,子进程会执行下面代码
        printf("I am the child process, my PID is %d\n", getpid());  
        printf("My parent's PID is %d\n", getppid());  
    } else {  
        // fork()返回非零值,表示这是父进程,返回值是子进程的PID,父进程执行下面代码
        printf("I am the parent process, my PID is %d\n", getpid());  
        printf("My child's PID is %d\n", pid);  
    } 
    return 0;  
}

如上面的代码,其中globvarglobbuf都是全局变量,而localvar是局部变量,当fork成功后,子进程会把父进程的globvarglobbuflocalvar继承过来,其实也就是共享过来,它们的值也和父进程的值一样。

这就是fork后子进程与父进程共享数据,其实也就是有点懒加载的味道,你想啊如果孵化子进程的时候一上来就要把各种数据都复制一份,首先会出现浪费的情况,因为这些数据有可能很多是子进程和父进程都不会去修改的,其次孵化子进程的速度肯定慢。

同样还是使用上面的例子,来介绍下写时复制,如下代码

    } else if (pid == 0) {  
        //pid == 0,则代表是fork成功的子进程,子进程会执行下面代码
        localvar = 100;
        globvar = 888;
        省略其他代码......
    }

上面代码,当子进程fork成功后,只是把localvarglobvar修改为100和888,则这时候localvarglobvar在子进程复制出自己单独的一份数据,与父进程的localvarglobvar已经不是共享了,而globbuf变量还是共享状态。

这位观众又说到:“谢谢您的解答,我还有个大胆的想法,感觉您只是简单的fork了子进程,如果孵化子进程的工作不由您来做,而是让init进程启动systemserver进程,systemserver进程来fork子进程,这样做的话会节省内存开销 (因为zygote进程不需要启动),同时加快孵化子进程的速度 (因为由您孵化子进程需要跨进程通信,而直接由systemserver就不用跨进程了)。不知道我这粗略的想法对不对。”

zygote:“哈哈,你这想法非常大胆,但是会有一个严重的问题,上面也提过fork机制子进程会与父进程共享非常多的数据,而systemserver中的很多的数据首先是非常重要及对安全性要求非常高的,这些数据如果被子进程共享的话,想想后果都非常可怕。还有systemserver中的非常多的数据对于子进程是完全无用的 (比如binder等相关数据binder驱动描述符了等),因为子进程需要自己的binder数据。因此fork子进程需要一个干净纯粹的环境。”

zygote拖了拖腮帮子继续说到:“而我可以提供这个非常干净的环境,别以为这样就完事了,还没有,你想啊一个可运行Java代码的进程肯定是需要加载JVM (Java虚拟机)的,如果每个子进程都加载一次JVM,那它们的启动速度可想而知了。作为母亲的我怎么可能让这种事情发生呢,我本着能让自己多吃苦多受罪也不能让孩子们吃一丁点苦的原则,我会加载JVM以及一些公共的资源,这样当子进程fork成功后,它们一“出生”就有了JVM,它们的启动速度可是杠杠硬啊。”

这位观众有些羞愧的说到:“不好意思啊,我这大胆的想法太过于鲁莽了,您说的我都明白了,谢谢。”

zygote:“没事,技术探讨不分对错,我也像我的父亲init进程一样拥有很多的孩子,下图是我和我妹及我的部分家族成员。”

image

如上图,zygote64是我,我的pid是1221,可以看出所有的子进程都基本是由我fork出来的。

主持人:“像您这么有名气,您能分享下您的成名过程吗,供年轻人参考参考,还有您是基于什么样的机缘巧合要立志成名的。”

zygote:“其实也没啥机缘巧合,无非是榜样的力量,当我刚出生不久的时候,我父亲init就是我的榜样,当我看到他有那么多的孩子 (子进程),我就立志也要像他一样能拥有很多的孩子 (子进程),我特别特别喜欢孩子。为了成名我可是吃了不少苦、做了非常多的努力。那就从我的出生说起吧。”

我的出生

我的出生要从init脚本文件说起。

主持人:“为啥从脚本文件说起?”

zygote:“是这样的,大家都知道我的父亲是init进程,因为它的子进程是非常非常多的,这么多子进程何时创建、创建之前需要执行哪些命令又更是多上加多,这么多的信息它完全是无招架之力,为了解决这个问题它创建了init脚本语言,哪个子进程需要创建,则配置自己的init脚本语言即可。”

主持人:“了解了解,那您继续说吧。”

下面是我和我妹的init脚本语言:

//文件路径:system/core/rootdir/init.zygote64_32.rc

//名为zygote的service,在fork成功后会执行 /system/bin/app_process64 可执行文件,它是64为的,后面是跟的参数,--start-system-server是代表要fork systemserver进程,--socket-name代表启动的server socket的名字
service zygote /system/bin/app_process64 -Xzygote /system/bin --zygote --start-system-server --socket-name=zygote
    //该service属于main类别
    class main
    priority -20
    //user是root级别的
    user root
    group root readproc reserved_disk
    socket zygote stream 660 root system
    socket usap_pool_primary stream 660 root system
    
    //在重启的时候会重新启动下面这些服务
    onrestart exec_background - system system -- /system/bin/vdc volume abort_fuse
    onrestart write /sys/power/state on
    onrestart restart audioserver
    onrestart restart cameraserver
    onrestart restart media
    onrestart restart media.tuner
    onrestart restart netd
    onrestart restart wificond
    task_profiles ProcessCapacityHigh MaxPerformance
    critical window=${zygote.critical_window.minute:-off} target=zygote-fatal

//名为zygote_secondary的service,fork成功后会执行/system/bin/app_process32可执行文件,它是32位的,后面是可执行文件跟随的参数,--socket-name代表启动的server socket的名字,--enable-lazy-preload代表不需要预加载各种资源
service zygote_secondary /system/bin/app_process32 -Xzygote /system/bin --zygote --socket-name=zygote_secondary --enable-lazy-preload
    class main
    priority -20
    user root
    group root readproc reserved_disk
    socket zygote_secondary stream 660 root system
    socket usap_pool_secondary stream 660 root system
    onrestart restart zygote
    task_profiles ProcessCapacityHigh MaxPerformance

上面脚本文件配置了两个服务zygotezygote_secondary,下面简单列下它们的区别吧:

  1. zygote服务:它的可执行文件是/system/bin/app_process64,参数分别为–zygote --start-system-server --socket-name=zygote
  2. zygote_secondary服务:它的可执行文件是/system/bin/app_process32,参数分别为–zygote --socket-name=zygote_secondary --enable-lazy-preload

先记住上面的几个参数,在用到它们的时候在解释。

配置了上面的init脚本文件,还需要在init.rc脚本文件中配置何时启动zygotezygote_secondary服务,如下:

//文件路径:system/core/rootdir/init.rc
on zygote-start && property:ro.crypto.state=unencrypted
    wait_for_prop odsign.verification.done 1
    # A/B update verifier that marks a successful boot.
    exec_start update_verifier_nonencrypted
    start statsd
    start netd
    start zygote
    start zygote_secondary

init进程会在对应的时机,分别启动zygotezygote_secondary服务,启动这俩服务会fork (孵化)zygote64zygote进程,进而会分别执行/system/bin/app_process64和/system/bin/app_process32可执行文件,这俩可执行文件最终都会执行到app_main.cppmain方法,如下代码:

//文件路径:cmds/app_process/app_main.cpp
int main(int argc, char* const argv[])
{
    省略代码......
}

执行到app_main.cppmain方法,我和我妹妹就出生了。

主持人:“我记得要想做成一件事情,得需要列一些计划,您作为成功人士应该也不例外吧,若有计划的话能否讲讲您的计划?”

zygote:“是的,我确实列了很多的计划,那我就来分享给大家。”

我的计划

我的计划主要分为:解析参数、启动JVM、拦截native线程创建、注册所有JNI方法、进入Java世界、解析参数、预加载资源、启动systemserver进程、可孵化App进程。计划的前一步都是在为计划的后一步做铺垫。

主持人:“那就依次来介绍下我的计划,把您的成名之路分享给大家吧。”

解析参数

zygote:“解析参数作为计划的第一步,主要是把脚本文件中的参数进行解析。”

还记得在我的出生介绍过我和我妹妹的init脚本信息吗,在脚本信息中会传递一些参数,会在app_main.cppmain方法中解析这些参数,这些参数可是非常的重要。有非常重要的一点再次提醒下我和我妹可是两个不同进程,是分别执行app_main.cpp的main方法

传递给我的参数有–zygote、–start-system-server、–socket-name=zygote,其中start-system-server则代表我会启动systemserver进程,socket-name则用来建立socket通信,也就是systemserver进程想要孵化子进程的话,则会通过socket发信息给我

传递给我妹的参数有–zygote、–socket-name=zygote_secondary --enable-lazy-preload,这里只解释下enable-lazy-preload,它代表不需要预加载公共资源

这些参数会被重新放置在类型为Vectorargs变量中,该变量会被传递给上层。

产物

会生成一个Vectorargs变量,它装载了init脚本传递过来的参数。

启动JVM

zygote:“启动JVM作为第二步,JVM大家肯定都非常熟悉了,Java/Kotlin代码要想运行肯定是离不开它的,为了不让每个fork出来的子进程在重复的启动JVM,也为了让子进程启动速度更快。因此我会把JVM启动,这样子进程再fork成功后就能直接使用我启动的JVM实例了。同时启动JVM也是最关键最核心的一步,没有它则后面的计划根本执行不了。启动JVM的工作我是交给了AppRuntime,而它继承了AndroidRuntime,那就有请它俩来给大家介绍下吧。”

//文件路径:cmds/app_process/app_main.cpp
int main(int argc, char* const argv[])
{
    //构造AppRuntime实例
    AppRuntime runtime(argv[0], computeArgBlockSize(argc, argv));
    
    省略代码......
    //zygote值为true
    if (zygote) {
        //调用start方法开始启动JVM,并做一些其他的初始化工作
        runtime.start("com.android.internal.os.ZygoteInit", args, zygote);
    }
    
    省略代码......
  
}

AppRuntime:“可以看下上面的代码,使用init脚本文件传递过来的参数初始化一个AppRuntime的实例,进而调用我的start方法会把com.android.internal.os.ZygoteInitargszygote为true,这几个参数传递给我。”

主持人:“方便解释下这几个参数吗?”

AppRuntime:“好的,com.android.internal.os.ZygoteInit聪明的你一定能看出它是一个Java类,对的它就是zygote进程进入Java世界的入口类,args解析参数阶段的产物。进入start方法就交给AndroidRuntime了。”

启动何种JVM

AndroidRuntime:“在启动JVM之前,我会对一些目录进行检查比如/apex/com.android.art等,检查通过后,就准备启动JVM了,我采用解耦式来启动JVM。”

主持人:“解耦式?这个名字很新鲜啊,给解释下呗。”

AndroidRuntime:“解耦式就是指把启动JVM与启动何种JVM分离开,我AndroidRuntime只定义启动JVM的动作和返回信息,具体是启动Dalvik JVM还是ART JVM甚至是别的类型的JVM,我通通都不关心。”

主持人:“明白,也就是您只定义一些规范和接口,不关心实现者的具体实现。”

AndroidRuntime:“对的,我定义了如下接口,请看如下代码。”

struct JniInvocationImpl {
   省略代码......

  // Function pointers to methods in JNI provider.
  jint (*JNI_GetDefaultJavaVMInitArgs)(void*);
 
  //创建虚拟机的方法
  jint (*JNI_CreateJavaVM)(JavaVM**, JNIEnv**, void*);
  //创建或者获取JavaVM实例
  jint (*JNI_GetCreatedJavaVMs)(JavaVM**, jsize, jsize*);
};

如上JniInvocationImpl结构体,咱们主要看创建JVM的接口JNI_CreateJavaVM,它的第一个参数类型是JavaVM类型的,第二个参数是JNIEnv类型的,这两个参数都是out类型的,也就是具体实现者创建JVM成功后,会把自己的实现的JavaVMJNIEnv实例赋值给这两个参数,而第三个参数是启动JVM需要传递的参数。

JavaVMJNIEnv它们到底有啥作用呢?

咱们声明了创建JVM的JNI_CreateJavaVM方法指针,既然创建了JVM,那肯定还需要一些与JVM进行交流的机制比如销毁JVM,那JavaVM的作用就是与JVM进行交流的比如声明DestroyJavaVM方法来销毁JVM,而具体如何销毁JVM,是由创建JVM的具体实现者提供的。一个进程只存在一个JavaVM实例。

JNIEnv大家肯定在jni方法中经常看到,它的主要作用是让native代码 (c/c++)可以操控Java层的类、对象、类的方法、对象的方法。比如CallStaticVoidMethodV方法的作用就是让native代码可以调用Java的某个类的静态方法。一个线程也只存在一个JNIEnv实例。

不管是JavaVM还是JNIEnv,只是定义了一些接口,不管是创建JVM的接口JNI_CreateJavaVM还是该接口返回的JavaVMJNIEnv到底是什么类型的实例,都是由创建JVM的真正实现者来实现的。

下面先来看下它们的声明,请自行取阅:

//文件路径:libnativehelper/include_jni/jni.h

struct _JavaVM {
    const struct JNIInvokeInterface* functions;

#if defined(__cplusplus)
    jint DestroyJavaVM()
    { return functions->DestroyJavaVM(this); }
    jint AttachCurrentThread(JNIEnv** p_env, void* thr_args)
    { return functions->AttachCurrentThread(this, p_env, thr_args); }
    jint DetachCurrentThread()
    { return functions->DetachCurrentThread(this); }
    jint GetEnv(void** env, jint version)
    { return functions->GetEnv(this, env, version); }
    jint AttachCurrentThreadAsDaemon(JNIEnv** p_env, void* thr_args)
    { return functions->AttachCurrentThreadAsDaemon(this, p_env, thr_args); }
#endif /*__cplusplus*/
};
typedef _JavaVM JavaVM;

  
//文件路径:libnativehelper/include_jni/jni.h

struct _JNIEnv {
    /* do not rename this; it does not seem to be entirely opaque */
    //初始化了它,所有的方法的实现都是调用它
    const struct JNINativeInterface* functions;

    #if defined(__cplusplus)

    jint GetVersion()
    { return functions->GetVersion(this); }

    jclass DefineClass(const char *name, jobject loader, const jbyte* buf,
    jsize bufLen)
    { return functions->DefineClass(this, name, loader, buf, bufLen); }

    jclass FindClass(const char* name)
    { return functions->FindClass(this, name); }

     void CallStaticVoidMethod(jclass clazz, jmethodID methodID, ...)
    {
      va_list args;
      va_start(args, methodID);
      functions->CallStaticVoidMethodV(this, clazz, methodID, args);
      va_end(args);
    }
    
    void CallStaticVoidMethodV(jclass clazz, jmethodID methodID, va_list args)
    { functions->CallStaticVoidMethodV(this, clazz, methodID, args); }
    void CallStaticVoidMethodA(jclass clazz, jmethodID methodID, const jvalue* args)
    { functions->CallStaticVoidMethodA(this, clazz, methodID, args); }
  
    省略代码......
}
  
typedef _JNIEnv JNIEnv;

主持人:“接口我看到了,那到底启动的是何种类型的JVM呢?”

AndroidRuntime:“启动JVM之前是需要传递各种参数的,在Android13上启动的是ART JVM,具体实现是在libart.so库中。JavaVM的真正实例是JavaVMExt类型,JNIEnv的真正实例是JNIEnvExt类型。”

如下查找创建JVM的真正实现者相关代码,请自行取阅:

//文件路径:libnativehelper/JniInvocation.c
//该方法主要是查找创建JVM的真正实现者
bool JniInvocationInit(struct JniInvocationImpl* instance, const char* library_name) {
    #ifdef __ANDROID__
    char buffer[PROP_VALUE_MAX];
    #else
    char* buffer = NULL;
    #endif
    //获取library_name,这时候它的值是 libart.so
    library_name = JniInvocationGetLibrary(library_name, buffer);
    DlLibrary library = DlOpenLibrary(library_name);
    
    省略代码......

  DlSymbol JNI_GetDefaultJavaVMInitArgs_ = FindSymbol(library, "JNI_GetDefaultJavaVMInitArgs");
  if (JNI_GetDefaultJavaVMInitArgs_ == NULL) {
    return false;
  }

  DlSymbol JNI_CreateJavaVM_ = FindSymbol(library, "JNI_CreateJavaVM");
  if (JNI_CreateJavaVM_ == NULL) {
    return false;
  }

  DlSymbol JNI_GetCreatedJavaVMs_ = FindSymbol(library, "JNI_GetCreatedJavaVMs");
  if (JNI_GetCreatedJavaVMs_ == NULL) {
    return false;
  }

  //下面代码把libart.so的相关方法赋值给instance
  instance->jni_provider_library_name = library_name;
  instance->jni_provider_library = library;
  instance->JNI_GetDefaultJavaVMInitArgs = (jint (*)(void *)) JNI_GetDefaultJavaVMInitArgs_;
  instance->JNI_CreateJavaVM = (jint (*)(JavaVM**, JNIEnv**, void*)) JNI_CreateJavaVM_;
  instance->JNI_GetCreatedJavaVMs = (jint (*)(JavaVM**, jsize, jsize*)) JNI_GetCreatedJavaVMs_;

  return true;
}

创建JVM相关代码,请自行取阅:

 
//文件路径:core/jni/AndroidRuntime.cpp
  
void AndroidRuntime::start(const char* className, const Vector<String8>& options, bool zygote)
{
    省略代码......
  
    /* start the virtual machine */
    JniInvocation jni_invocation;
  
    //调用jni_invocation的Init方法,会去找创建JVM的真正实现者是谁
    jni_invocation.Init(NULL);
    JNIEnv* env;
    
    //会调用下面的startVm方法,开始创建JVM,mJavaVM是JavaVM类型,env是JNIEnv类型
    if (startVm(&mJavaVM, &env, zygote, primary_zygote) != 0) { //zygote为true primary_zygote为true
        return;
    }
                                          
    省略代码......
}
                                          
int AndroidRuntime::startVm(JavaVM** pJavaVM, JNIEnv** pEnv, bool zygote, bool primary_zygote)
{
    JavaVMInitArgs initArgs;
    
    省略各种创建JVM参数的代码.....
  
    /*
     * Initialize the VM.
     *
     * The JavaVM* is essentially per-process, and the JNIEnv* is per-thread.
     * If this call succeeds, the VM is ready, and we can start issuing
     * JNI calls.
     */
    //调用JNI_CreateJavaVM创建JVM
    if (JNI_CreateJavaVM(pJavaVM, pEnv, &initArgs) < 0) {
        ALOGE("JNI_CreateJavaVM failed\n");
        return -1;
    }

    return 0;
}
产物

该阶段会启动ART JVM,启动成功后会创建JavaVMExt的对象赋值给pJavaVM,同时也会创建JNIEnvExt的对象赋值给pEnv,它们可都是在后面计划中起着非常重要的作用。

启动ART JVM后预示着可以运行Java代码了,但是先别急还有一些事情要做。

拦截native线程创建

主持人:“拦截native线程创建,这个计划着实有些让人摸不着头脑。”

AndroidRuntime:“哈哈,确实有些晕圈,在介绍该计划之前,我先介绍下Android中的线程吧。”

Android中的线程分为两大类:Java线程native线程。而native线程又可分为:纯native线程可调用Java代码的native线程

可调用Java代码的native线程是指该native线程的native代码是可以调用Java层的类、对象、类的方法、对象方法等;反之纯native线程就是指native线程的native代码不能调用Java层的任何代码。

对于Java线程的Java代码是可以通过jni调用native层的代码,而native层的代码如果要想调用Java层代码就需要用到JNIEnv对象,而该对象是在每个Java线程创建成功后会自动创建的。

而对于可调用Java代码的native线程,也需要使用到JNIEnv对象才能调用Java层的代码,因此拦截native线程所做的事情就是针对 可调用Java代码的native线程 拦截它的创建过程,这样就可以把JNIEnv对象交给它,进而可以调用Java层代码。

主持人:“创建的JNIEnv对象从何而来呢?”

还记得在启动JVM阶段,libart.so库创建的JavaVMExt对象吗?它可是每个进程都拥有的,调用它的AttachCurrentThread方法就可以创建JNIEnvExt类型的对象 (JNIEnvExt是JNIEnv的子类)

如下是部分代码,请自行取阅:

void AndroidRuntime::start(const char* className, const Vector<String8>& options, bool zygote)
{
    省略代码......
  
    /* start the virtual machine */
    JniInvocation jni_invocation;
  
    //调用jni_invocation的Init方法,会去找创建JVM的真正实现者是谁
    jni_invocation.Init(NULL);
    JNIEnv* env;
    
    //会调用下面的startVm方法,开始创建JVM,mJavaVM是JavaVM类型,env是JNIEnv类型
    if (startVm(&mJavaVM, &env, zygote, primary_zygote) != 0) { //zygote为true primary_zygote为true
        return;
    }
         
     /*
     * Register android functions.
     */
    if (startReg(env) < 0 ) {
        ALOGE("Unable to register all android natives\n");
        return;
    }
    省略代码......
}
  
int AndroidRuntime::startReg(JNIEnv* env)
{
    
    省略代码......
    androidSetCreateThreadFunc((android_create_thread_fn) javaCreateThreadEtc);

    省略代码......
    return 0;
}

产物

该阶段可调用Java代码的native线程的native代码是可以调用Java层的类、对象、类的方法、对象方法等Java层的代码。

注册所有JNI方法

JNI,它是Java Native Interface的缩写,它定义了Java层与native层之间的接口,这些接口约定了Java层与native层方法调用的规则。

JNI到底长啥样子,看下面代码:

//文件路径:/libnativehelper/include_jni/jni.h

typedef struct {
    //name对应Java层的方法,在Java类中使用native关键字来标注该方法
    const char* name;
    //方法签名,会表明方法的参数、返回值等信息
    const char* signature;
    //native方法指针,也就是在Java层调用名称为name的方法,最终会调用到fnPtr
    void*       fnPtr;
} JNINativeMethod;

是不是有些抽象是吧,那举些例子吧,如下代码:

  
public class ZygoteInit{
    省略代码......
  
    private static native void nativeZygoteInit();
  
    省略代码......
}

//文件路径:core/jni/AndroidRuntime.cpp

const JNINativeMethod methods[] = {
        { "nativeZygoteInit", "()V",
            (void*) com_android_internal_os_ZygoteInit_nativeZygoteInit },
    };

如上例子,ZygoteInit类的nativeZygoteInit方法是一个native方法,该方法没有参数,也没有返回值,因此它的签名是 ()V ,而它在native层的真正实现者是com_android_internal_os_ZygoteInit_nativeZygoteInit这个方法。

在Android系统中像ZygoteInit这样具有native方法的类很多比如ParcelBinder等,注册所有的JNI方法这里的所有就是指这些具有native方法的Java类。而注册就是调用JNIEnv对象的RegisterNatives方法进行注册,注册后才可以在Java层调用native层的方法。

如下是注册所有JNI方法的代码,有兴趣自行取阅:

int AndroidRuntime::startReg(JNIEnv* env)
{
    省略代码......
    
    //调用register_jni_procs方法注册所有的JNI方法,gRegJNI保存了所有的JNI方法
    if (register_jni_procs(gRegJNI, NELEM(gRegJNI), env) < 0) { //注册各种jni方法
        env->PopLocalFrame(NULL);
        return -1;
    }
    
    省略代码......
  
    return 0;
}
  
static int register_jni_procs(const RegJNIRec array[], size_t count, JNIEnv* env)
{
    for (size_t i = 0; i < count; i++) {
        if (array[i].mProc(env) < 0) {
#ifndef NDEBUG
            ALOGD("----------!!! %s failed to load\n", array[i].mName);
#endif
            return -1;
        }
    }
    return 0;
}
  
static const RegJNIRec gRegJNI[] = {
        REG_JNI(register_com_android_internal_os_RuntimeInit),
        REG_JNI(register_com_android_internal_os_ZygoteInit_nativeZygoteInit),
        省略代码......
        REG_JNI(register_android_os_Process),
        REG_JNI(register_android_os_SystemProperties),
        REG_JNI(register_android_os_Binder),
        REG_JNI(register_android_os_Parcel),
        省略代码......
}
  

注意,如上注册所有JNI方法的代码,其中用到的JNIEnv对象是在启动JVM那阶段的产生的JNIEnv对象。

产物

该阶段注册了所有的JNI方法,这样当fork出来的子进程就不需要再次注册所有的JNI方法了,因为它们已经从zygote进程“继承”了,这样做可以加快子进程的启动速度。

进入Java世界

以上的计划都是在native世界,那我们总得来到Java世界吧,init脚本携带的参数也解析了、ART JVM也启动了、所有的JNI方法也都注册了,有了这些基础就已经具备了进入Java世界的条件,进入Java世界唯一缺少的就是进入哪个类的哪个方法了?

还记得在启动JVM的时候,调用AppRuntimestart方法传递的第一个参数 com.android.internal.os.ZygoteInit ,它就是要进入Java世界的入口类,而要进入的方法是它的main方法。会调用JNIEnvCallStaticVoidMethod方法进入ZygoteInit类的main方法,当然也会把一些参数传递给main方法 (如init脚本携带的参数)

下面是对应代码,请自行取阅:

void AndroidRuntime::start(const char* className, const Vector<String8>& options, bool zygote)
{
    省略代码......
  
    /*
     * We want to call main() with a String array with arguments in it.
     * At present we have two arguments, the class name and an option string.
     * Create an array to hold them.
     */
    jclass stringClass;
    jobjectArray strArray;
    jstring classNameStr;

    //使用JNIEnv来查找java/lang/String的class
    stringClass = env->FindClass("java/lang/String");
    assert(stringClass != NULL);
    strArray = env->NewObjectArray(options.size() + 1, stringClass, NULL);
    assert(strArray != NULL);
    
    //className就是 com.android.internal.os.ZygoteInit
    classNameStr = env->NewStringUTF(className);
    assert(classNameStr != NULL);
    env->SetObjectArrayElement(strArray, 0, classNameStr);

    for (size_t i = 0; i < options.size(); ++i) {
        jstring optionsStr = env->NewStringUTF(options.itemAt(i).string());
        assert(optionsStr != NULL);
        env->SetObjectArrayElement(strArray, i + 1, optionsStr);
    }

    /*
     * Start VM.  This thread becomes the main thread of the VM, and will
     * not return until the VM exits.
     */
    char* slashClassName = toSlashClassName(className != NULL ? className : "");
    jclass startClass = env->FindClass(slashClassName);
    if (startClass == NULL) {
        没找到ZygoteInit类
    } else {
        //查找main方法
        jmethodID startMeth = env->GetStaticMethodID(startClass, "main",
            "([Ljava/lang/String;)V");
        if (startMeth == NULL) {
            没找到main方法
        } else {
            //调用ZygoteInit的main方法
            env->CallStaticVoidMethod(startClass, startMeth, strArray);

#if 0
            if (env->ExceptionCheck())
                threadExitUncaughtException(env);
#endif
        }
    }
  
    //退出Java世界后需要释放slashClassName,并且把JVM销毁掉
    free(slashClassName);

    ALOGD("Shutting down VM\n");
    if (mJavaVM->DetachCurrentThread() != JNI_OK)
        ALOGW("Warning: unable to detach main thread\n");
    if (mJavaVM->DestroyJavaVM() != 0)
        ALOGW("Warning: VM did not shut down cleanly\n");
}

如上代码,进入Java世界后,会停留在Java世界,除非由于各种原因退出Java世界后会销毁JVM等操作。

产物

该阶段进入了ZygoteInit类的main方法,也代表着完全的进入了Java世界。

解析参数

从native层是传递了多个参数过来的,因此要在ZygoteInit类的main方法把这些参数解析出来,而这些参数大部分来自init脚本定义的参数,那我们再次把init脚本的部分信息请出来供大家看下 (如下代码)

//文件路径:system/core/rootdir/init.zygote64_32.rc

//名为zygote的service,在fork成功后会执行 /system/bin/app_process64 可执行文件,它是64为的,后面是跟的参数,--start-system-server是代表要fork systemserver进程,--socket-name代表启动的server socket的名字
service zygote /system/bin/app_process64 -Xzygote /system/bin --zygote --start-system-server --socket-name=zygote
    
    省略其他配置......
  
service zygote_secondary /system/bin/app_process32 -Xzygote /system/bin --zygote --socket-name=zygote_secondary --enable-lazy-preload
    
    省略其他配置......

同时结合ZygoteInit类的main方法的解析参数代码 (如下)

  public static void main(String[] argv) {
  
        省略代码......
  
        try {
  
            省略代码......
            //是否要启动systemserver进程
            boolean startSystemServer = false;
            String zygoteSocketName = "zygote";
            String abiList = null;
            //是否是推迟预加载资源
            boolean enableLazyPreload = false; 
          
            for (int i = 1; i < argv.length; i++) {
                //zygote64进程需要启动systemserver进程
                if ("start-system-server".equals(argv[i])) {
                    startSystemServer = true;
                } else if ("--enable-lazy-preload".equals(argv[i])) {
                    //zygote进程该值为true,代表不需要预加载资源
                    enableLazyPreload = true;
                } else if (argv[i].startsWith(ABI_LIST_ARG)) { 
                  //这个值从native层传递过来的,是从 adb shell getprop ro.product.cpu.abilist64获取的 值是arm64-v8a
                    abiList = argv[i].substring(ABI_LIST_ARG.length());
                } else if (argv[i].startsWith(SOCKET_NAME_ARG)) {
                    //app_process64为zygote, app_process32为zygote_secondary
                    zygoteSocketName = argv[i].substring(SOCKET_NAME_ARG.length());
                } else {
                    throw new RuntimeException("Unknown command line argument: " + argv[i]);
                }
            }

            省略代码......
        } catch (Throwable ex) {
            Log.e(TAG, "System zygote died with fatal exception", ex);
            throw ex;
        } finally {
            if (zygoteServer != null) {
                zygoteServer.closeServerSocket();
            }
        }

        // We're in the child process and have exited the select loop. Proceed to execute the
        // command.
        if (caller != null) {
            caller.run();
        }
    }

zygote:“如上代码,我zygote64进程,startSystemServer为true代表需要启动systemserver进程,enableLazyPreload为false代表需要预加载资源,zygoteSocketName值为zygote。”

“而我妹zygote进程,startSystemServer为false代表不需要启动systemserver进程,enableLazyPreload为true代表不需要预加载资源,zygoteSocketName值为zygote_secondary。”

预加载资源

预加载资源就是把每个子进程都会用到的通用的、静态的资源提前在我zygote内加载,这样当子进程被fork出来后就可以不需要在执行这些资源的加载流程了,完全加快了子进程的启动速度。

只有我zygote才会预加载资源,而我妹是不会的,其主要原因是因为我是作为fork子进程的主力,基本所有的子进程都是由我孵化的。而我妹只是一个辅助而已,有可能在Android设备打开的这段时间内我妹基本上不会孵化进程,或者说她孵化的子进程非常少,而为了这么少的子进程来提前预加载资源这不是大大的浪费珍贵的内存吗,这是大大的“犯罪”。

而通用的、静态的资源,这里的通用指所有子进程都会用到的,而静态指的是这些资源加载到内存后基本是不会发生变化的。

通用的、静态的资源有各种Java类,这些类提前加载到JVM内后,在子进程中就不需要再次加载它们了。还有Resource资源,比如基础图片、文字等,这些Resource资源提前加载进来子进程也不需要再次加载它们了。当然还有别的资源比如共享库、字体资源等。

下面是相关代码,请自行取阅:

public static void main(String[] argv) {
           
            省略代码......
  
            // In such cases, we will preload things prior to our first fork.
            //zygote64进程,该值为false
            if (!enableLazyPreload) {
                bootTimingsTraceLog.traceBegin("ZygotePreload");
                EventLog.writeEvent(LOG_BOOT_PROGRESS_PRELOAD_START,
                        SystemClock.uptimeMillis());
                //预加载资源
                preload(bootTimingsTraceLog); 
                EventLog.writeEvent(LOG_BOOT_PROGRESS_PRELOAD_END,
                        SystemClock.uptimeMillis());
                bootTimingsTraceLog.traceEnd(); // ZygotePreload
            }
        
            省略代码......
    }
  
static void preload(TimingsTraceLog bootTimingsTraceLog) {
        Log.d(TAG, "begin preload");
        bootTimingsTraceLog.traceBegin("BeginPreload");
        beginPreload();
        bootTimingsTraceLog.traceEnd(); // BeginPreload
        bootTimingsTraceLog.traceBegin("PreloadClasses");
        //虚拟机预加载各种类
        preloadClasses();
        bootTimingsTraceLog.traceEnd(); // PreloadClasses
        bootTimingsTraceLog.traceBegin("CacheNonBootClasspathClassLoaders");
        cacheNonBootClasspathClassLoaders();
        bootTimingsTraceLog.traceEnd(); // CacheNonBootClasspathClassLoaders
        bootTimingsTraceLog.traceBegin("PreloadResources");
        //预加载Resource
        preloadResources();
        bootTimingsTraceLog.traceEnd(); // PreloadResources
        Trace.traceBegin(Trace.TRACE_TAG_DALVIK, "PreloadAppProcessHALs");
        nativePreloadAppProcessHALs();
        Trace.traceEnd(Trace.TRACE_TAG_DALVIK);
        Trace.traceBegin(Trace.TRACE_TAG_DALVIK, "PreloadGraphicsDriver");
        maybePreloadGraphicsDriver();
        Trace.traceEnd(Trace.TRACE_TAG_DALVIK);
        //预加载共享lib
        preloadSharedLibraries();
        //预加载字体资源
        preloadTextResources(); 
        // Ask the WebViewFactory to do any initialization that must run in the zygote process,
        // for memory sharing purposes.
        WebViewFactory.prepareWebViewInZygote();
        endPreload();
        warmUpJcaProviders();
        Log.d(TAG, "end preload");

        sPreloadComplete = true;
    }
产物

该阶段预加载了各种资源,这样在子进程fork成功后就不需要再次加载了,大大的提高了子进程的启动速度。

启动systemserver进程

经过前面的几个阶段,是完全已经具备了fork子进程的能力了,但是作为所有子进程的“长子”systemserver进程,它是需要最先被启动的,只有它完全启动成功后,哪个子进程需要被fork都是由systemserver进程内发出的命令。

关于启动systemserver进程,在此节我们不做过多的讨论,会在systemserver进程那节咱们再来分析。

可孵化App进程

启动完毕systemserver进程后,前面的所有的铺垫工作启动ART JVM拦截native线程创建注册所有的JNI方法预加载资源启动systemserver进程都已经结束,那最后的工作内容就是孵化App进程,当systemserver进程发出孵化App进程的请求时候,zygote就开始孵化App进程。

那这就有个需要解决的问题了,systemserver进程和zygote64zygote进程完全不是一个进程,那它们之间应该采用何种通信方式呢?

到底是选用binder or socket呢 ?

我觉得需要从以下几方面考虑。

binder通信就一定比socket通信快吗?

大家都非常清楚binder通信要比socket通信快,但是这个快我觉得得有一个前提条件,那就是传递的数据量是不是比较大,比较大的话我觉得快,非常小的话就不至于快了,为啥这样说呢,还要从binder通信说起。

在binder通信中调用的方法的参数在通信过程中是只有一次拷贝,而像方法对应的code值 (int类型),它其实在通信过程中是需要两次拷贝的。而socket通信数据是需要两次拷贝的。

若采用binder通信调用了一个无参的方法,像code值在整个binder通信过程中是需要进行两次拷贝的;若采用socket通信实现同样功能的话,只需要传递一个类似于code值一样的简单类型,这时候的code值也是进行了两次拷贝。那在以上的情况下,就不见得binder通信比socket通信快了,甚至有可能比socket通信还慢,因为binder通信还要涉及到一些转换处理等流程。

若还是上面的情况,换成调用的方法的参数是简单类型,这时候也不见得binder通信比socket通信快了。

若采用binder通信,遇到哪些问题?需要做哪些处理?

采用binder通信的话,在fork子进程成功后需要做以下事情:

  1. 子进程是需要把从父进程继承过来的binder fd (文件描述符)关闭掉的
  2. binder使用mmap打开的匿名共享内存也需要ummap掉,因为这块内存是与zygote进程共享的,而其他子进程是完全不需要的。
  3. 子进程会继承父进程的线程,若线程存在锁的话也需要对锁进行特殊处理,否则子进程的继承过来的线程出现死锁情况。
  4. 当然除了上面还需要子进程重新把自己的binder驱动打开,在重新使用mmap打开自己的匿名共享内存。

上面想到的只是其中一部分,还有需要做的事情,因此如果采用binder通信的话,子进程需要做的事情真的非常多,这无疑增加了复杂度。

结合实际使用场景

systemserver进程是孵化App进程的发起方 (它是client端),而zygote是孵化App进程的执行方 (它是server端),非常的明确的一点client端只有一个,并且还有个很关键的一点它们之间传递的数据量不大

在基于传递的数据量不大情况下,binder通信不至于比socket通信快,并且若采用binder通信需要处理的问题确实比较多。基于以上分析反而binder通信的优势不明显了,而采用socket通信的话,首先是实现简单;其次是子进程fork成功后,只需要把继承过来的socket关闭掉即可,这时候在启动binder通信。

如上systemserver进程和zygote64zygote进程之间采用socket通信方式,zygote64zygote是作为server端,而systemserver是作为client端,zygote64zygote若没有孵化App进程的请求,则进入阻塞状态;如若有则开始孵化App进程,当孵化成功后,再次去查找是否有孵化APp进程的请,有孵化没有则进入阻塞状态。zygote64zygote就是这样周而复始的重复的执行着上面的流程。

关于如何孵化App进程的分析会在App启动流程章节再次进行探讨。

结束

主持人:“今天我们认识了zygote (真名是zygote64)和她的妹妹 (真名是zygote),zygote是孵化App进程的主力,而她的妹妹是作为辅助存在。zygote她是一个伟大的母亲,她为了让她的孩子们 (子进程)少受些‘累’,它把启动JVM注册所有的JNI方法预加载资源等这些事情提前做了,这样当她的孩子们在启动时候就不需要做这些重复的事情了,进而提升了它们的启动速度。并且她还启动了systemserver进程。关于zygote如何启动systemserver进程和孵化App进程的内容,会在后面节目中继续邀请她为大家分享。谢谢大家。”

请添加图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/774135.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

idea启用多个环境

背景 在平常的后端开发中&#xff0c;需要与前端联调&#xff0c;比较方便的是让前端直接连自己的本地环境&#xff08;毕竟每次都要打包部署到测试环境实在是太麻烦了&#xff09;。但是这样子也有点不好&#xff0c;就是自己功能还没写好呢&#xff0c;结果前端连着自己的环…

如何利用Kimi解读Kimi的KVCache技术细节

最近Kimi公布了一篇Mooncake: Kimis KVCache-centric Architecture for LLM Serving的文章&#xff0c;详细介绍了Kimi背后的推理架构&#xff0c;因此笔者想到用Kimi解读Kimi&#xff0c;梳理相关技术要点如下&#xff0c;供大家参考&#xff1a; 文章 "Mooncake: A KVCa…

2024年中国安防CIS市场现状及主要竞争企业分析

2024年中国安防CIS市场现状及主要竞争企业分析 CIS又名CMOS图像传感器&#xff0c;属于一种光学传感器&#xff0c;将光信号转换为电信号并通过读出电路转为数字化信号&#xff0c;是摄像头模组的核心元器件&#xff0c;可以用于手机、汽车、电脑、安防、消费等领域。不同应用领…

风电升压站3d动画演示定制确保每一名职工都能够安全、健康地工作

海上风电工程建设包括大量的吊装作业、架空作业、埋设作业以及电气作业&#xff0c;涉及面广&#xff0c;风险较高&#xff0c;因此在技能培训上需要格外重视&#xff0c;基于VR安全培训的广泛应用&#xff0c;企业逐渐开始引进VR虚拟仿真技术&#xff0c;利用视觉、听觉和亲身…

内存管理(RTOS)

目录 #RTOS内存管理介绍 #堆定义 #栈定义 #RTOS四种堆分配方案 #Heap_1.c #Heap_2.c #Heap_3.c #Heap_4.c #Heap_5.c #stm32cublemx对堆的配置 #配置堆相关函数 #申请内存函数 #钩子函数 前言&#xff1a;本课程参考韦东山老师视频&#xff0c;连接放在最后。 #R…

基于springboot的工作绩效管理系统的设计与实现+文档

&#x1f497;博主介绍&#x1f497;&#xff1a;✌在职Java研发工程师、专注于程序设计、源码分享、技术交流、专注于Java技术领域和毕业设计✌ 温馨提示&#xff1a;文末有 CSDN 平台官方提供的老师 Wechat / QQ 名片 :) Java精品实战案例《700套》 2025最新毕业设计选题推荐…

字节码编程javassist之定义各种属性

写在前面 源码 。 本文看下如何使用javassist来定义属性。 1&#xff1a;程序 package com.dahuyou.javassist.generateFieldAndMethod;import javassist.*;import java.lang.reflect.Method;public class JustDoIt111 {public static void main(String[] args) throws Exce…

【Python】已解决:NameError: name ‘reload’ is not defined

文章目录 二、可能出错的原因三、错误代码示例四、正确代码示例五、注意事项 已解决&#xff1a;NameError: name ‘reload’ is not defined 一、分析问题背景 在使用Python进行开发时&#xff0c;有时我们可能需要重新加载某个已经导入的模块&#xff0c;以便应用模块中的最…

idm下载慢怎么回事 idm批量导入下载使用方法

IDM (Internet Download Manager)是一款兼容性大&#xff0c;支持多种语言的下载管理软件&#xff0c;它可以自动检测并下载网页上的内容&#xff0c;这正是这一优点&#xff0c;使得它受到了广大用户的喜爱。在日常使用互联网的过程中&#xff0c;快速下载文件对我们来说非常重…

排序(1)

接下来&#xff0c;我们就来到了排序的章节&#xff0c;嘿嘿&#xff01;加油&#xff01; 冒泡排序 void BubbleSort(int* a,int n) {for(int j0;j<n;i){for(int i1;i<n-j;i){if(arr[i-1]>arr[i]){swap(&arr[i-1],&arr[i]);}}}} 插入排序 时间复杂度&…

【网工】关于链路聚合、静态路由、单臂路由的一个小实验

最近刚考完期末放暑假&#xff0c;那几天没看csdn结果有个朋友发了这样一个实验&#xff1a; 虽然晚了点 也不知道这位朋友还需不需要 但还是弄了出来 分享给大家 一起学习 下面是一些关键配置代码参考

h5 video 标签播放经过 java 使用 ws.schild( jave、ffmpeg ) 压缩后的 mp4 视频只有声音无画面的问题排查记录

1. 引入 ws.schild MAVEN 依赖&#xff1a; <dependency><groupId>ws.schild</groupId><artifactId>jave-all-deps</artifactId><version>3.5.0</version></dependency><dependency><groupId>ws.schild</grou…

你真的会ELISA加样吗?

在ELISA实验中&#xff0c;研究人员需要进行多次加样步骤完成实验操作。对于常规双抗体夹心法ELISA&#xff0c;一般有如下加样步聚&#xff0c;即加样本、加检测抗体、加酶结合物、加底物&#xff08;最后加终止液停止反应&#xff09;。 加样步骤基础知识 加样步骤中一般使用…

华为OD机试2024年最新题库 JAVA C卷+D卷

目录 专栏导读华为OD机试算法题太多了&#xff0c;知识点繁杂&#xff0c;如何刷题更有效率呢&#xff1f; 一、逻辑分析二、数据结构1、线性表① 数组② 双指针 2、map与list3、队列4、链表5、栈6、滑动窗口7、二叉树8、并查集9、矩阵 三、算法1、基础算法① 贪心思维② 二分查…

解决npm与yarn痛点:幽灵依赖与依赖分身

前言 在现代前端开发流程中&#xff0c;包管理工具扮演着至关重要的角色&#xff0c;其中npm和yarn是两个非常流行的JavaScript包管理工具。虽然它们为开发者提供了极大的便利&#xff0c;但也存在一些痛点&#xff0c;特别是关于“幽灵依赖&#xff08;Phantom Dependencies&a…

开放式耳机哪个牌子好?五款畅销产品推荐,免交智商税!

作为开放式耳机的测评博主&#xff0c;在最近又淘到了几款比较不错的开放式耳机&#xff0c;所以今天这篇文章&#xff0c;我也给大家推荐五款开放式耳机&#xff0c;内附还有我自己总结的开放式耳机的指南&#xff0c;希望各位小伙伴也能够看的开心&#xff0c;挑选到自己比较…

taoCMS v3.0.2 文件上传漏洞(CVE-2022-23880)

前言 CVE-2022-23880是一个影响taoCMS v3.0.2的任意文件上传漏洞。攻击者可以利用此漏洞通过上传特制的PHP文件在受影响的系统上执行任意代码。 漏洞细节 描述: 在taoCMS v3.0.2的文件管理模块中存在任意文件上传漏洞。攻击者可以通过上传恶意的PHP文件来执行任意代码。 影响…

YUM——简介、安装(Ubuntu22.04)

1、简介 YUM&#xff08;Yellowdog Updater, Modified&#xff09;是一个开源的命令行软件包管理工具&#xff0c;主要用于基于 RPM 包管理系统的 Linux 发行版&#xff0c;如 CentOS、Red Hat Enterprise Linux (RHEL) 和 Fedora。YUM 使用户能够轻松地安装、更新、删除和管理…

识别 Spring Cloud 配置文件的规则:Nacos, Bootstrap, Application

个人名片 &#x1f393;作者简介&#xff1a;java领域优质创作者 &#x1f310;个人主页&#xff1a;码农阿豪 &#x1f4de;工作室&#xff1a;新空间代码工作室&#xff08;提供各种软件服务&#xff09; &#x1f48c;个人邮箱&#xff1a;[2435024119qq.com] &#x1f4f1…
最新文章