一个iOS APP的启动过程

一个程序的入口是什么?

那肯定main()函数啊。这么说是没有错的,main()函数是我们的程序的入口,但我们应该知道的是,在开始执行我们的程序之前还有很多的准备工作要做,只有准备工作圆满完成,我们的程序才能被正确的执行。

main()函数执行之前都需要做哪些准备工作

1.读取可执行文件(Mach-O文件),从Mach-O文件中找到动态链接库dyld的地址,把dyld加载进来。
2.dyld先设置一下运行环境,配置环境变量,然后加载可执行文件和依赖动态库。
3.对可执行文件和相关动态库进行链接--用于修证符号指针,使其指向正确地址。
4.递归的对相关动态库进行初始化,对可执行文件初始化,这个过程中会注册Objc类;把category定义的各种方法、属性、协议等加入类的数组中;调用各个类的load方法。
5.dyld返回一个主程序的main()函数,开始执行main()函数。

一个断点引发的研究

我们先随便找个类加一个+load方法,然后在这个+load方法上打上断点,运行程序,看一下函数调用栈,你会发现什么


函数调用栈

程序的运行是从dyld开始的,我们可以从系统源码库中找到dyld的源码,从源码的dyldStartup.s文件中我们可以找到__dyld_start函数,这是一个汇编语言的代码,大家看注释就好了,可以看到在这个函数中调用了dyldbootstrap::start函数。

__dyld_start:
    pushq   $0      # push a zero for debugger end of frames marker
    movq    %rsp,%rbp   # pointer to base of kernel frame
    andq    $-16,%rsp       # force SSE alignment
    
    # call dyldbootstrap::start(app_mh, argc, argv, slide)
    movq    8(%rbp),%rdi    # param1 = mh into %rdi
    movl    16(%rbp),%esi   # param2 = argc into %esi
    leaq    24(%rbp),%rdx   # param3 = &argv[0] into %rdx
    movq    __dyld_start_static(%rip), %r8
    leaq    __dyld_start(%rip), %rcx
    subq     %r8, %rcx  # param4 = slide into %rcx
    call    __ZN13dyldbootstrap5startEPK11mach_headeriPPKcl 

        # clean up stack and jump to result
    movq    %rbp,%rsp   # restore the unaligned stack pointer
    addq    $16,%rsp    # remove the mh argument, and debugger end frame marker
    movq    $0,%rbp     # restore ebp back to zero
    jmp *%rax       # jump to the entry point

start函数是在dyldInitialization.cpp文件中,这个函数主要是一个dyld的自举,然后调用main()函数

//
//  This is code to bootstrap dyld.  This work in normally done for a program by dyld and crt.
//  In dyld we have to do this manually.
//
uintptr_t start(const struct mach_header* appsMachHeader, int argc, const char* argv[], intptr_t slide)
{
      //自举dyld,设置一些运行参数
    .....
        

    // run all C++ initializers inside dyld
    runDyldInitializers(dyldsMachHeader, slide, argc, argv, envp, apple);
    
    // if main executable was linked -pie, then randomize its load address
    if ( appsMachHeader->flags & MH_PIE )
        appsMachHeader = randomizeExecutableLoadAddress(appsMachHeader, &appsSlide);

    //完成自举,调用dyld的main()函数
    // now that we are done bootstrapping dyld, call dyld's main
    return dyld::_main(appsMachHeader, appsSlide, argc, argv, envp, apple);
}

至此,我们之前说的第一步1.读取可执行文件(Mach-O文件),从Mach-O文件中找到动态链接库dyld的地址,把dyld加载进来。已经完成了。
接下来就到了_main()函数,看到这里可能有人会想不是main()函数之前还有好多流程没走吗?怎么就到_main()函数了,其实这个_main()函数并不是我们程序中的main()函数,这个是dyld_main()函数,而且这个函数的返回值就是我们的主程序入口main()函数的地址,所以其实我们执行main()函数之前的准备工作中剩下的几步,都是在dyld_main()函数中执行的。

//
// Entry point for dyld.  The kernel loads dyld and jumps to __dyld_start which
// sets up some registers and call this function.
//
// Returns address of main() in target program which __dyld_start jumps to
//
uintptr_t
_main(const struct mach_header* mainExecutableMH, uintptr_t mainExecutableSlide, int argc, const char* argv[], const char* envp[], const char* apple[])
{
#pragma mark 2.dyld先设置一下运行环境,配置环境变量,然后加载可执行文件和依赖动态库。
    //2.1设置上下文
    setContext(mainExecutableMH, argc, argv, envp, apple);
    
    //2.2获取可执行文件路径
    sExecPath = apple[0];
    bool ignoreEnvironmentVariables = false;
#if __i386__
    if ( isRosetta() ) {
        // under Rosetta (x86 side)
        // When a 32-bit ppc program is run under emulation on an Intel processor,
        // we want any i386 dylibs (e.g. any used by Rosetta) to not load in the shared region
        // because the shared region is being used by ppc dylibs
        gLinkContext.sharedRegionMode = ImageLoader::kDontUseSharedRegion;
        ignoreEnvironmentVariables = true;
    }
#endif
    
    if ( sExecPath[0] != '/' ) {
        // have relative path, use cwd to make absolute
        char cwdbuff[MAXPATHLEN];
        if ( getcwd(cwdbuff, MAXPATHLEN) != NULL ) {
            // maybe use static buffer to avoid calling malloc so early...
            char* s = new char[strlen(cwdbuff) + strlen(sExecPath) + 2];
            strcpy(s, cwdbuff);
            strcat(s, "/");
            strcat(s, sExecPath);
            sExecPath = s;
        }
    }
    //2.3设置运行环境
    uintptr_t result = 0;
    sMainExecutableMachHeader = mainExecutableMH;
    sMainExecutableIsSetuid = issetugid();
    if ( sMainExecutableIsSetuid )
        pruneEnvironmentVariables(envp, &apple);
    else
        checkEnvironmentVariables(envp, ignoreEnvironmentVariables);
    if ( sEnv.DYLD_PRINT_OPTS ) 
        printOptions(argv);
    if ( sEnv.DYLD_PRINT_ENV ) 
        printEnvironmentVariables(envp);
    getHostInfo();
    // install gdb notifier
    // 2.4注册gdb的监听
    stateToHandlers(dyld_image_state_dependents_mapped, sBatchHandlers)->push_back(notifyGDB);
    // make initial allocations large enough that it is unlikely to need to be re-alloced
    sAllImages.reserve(200);
    sImageRoots.reserve(16);
    sAddImageCallbacks.reserve(4);
    sRemoveImageCallbacks.reserve(4);
    sImageFilesNeedingTermination.reserve(16);
    sImageFilesNeedingDOFUnregistration.reserve(8);
    
    try {
        // instantiate ImageLoader for main executable
        //2.5实例化一个主程序可执行文件的镜像
        //主程序镜像会被加入`sAllImages`数组中
        sMainExecutable = instantiateFromLoadedImage(mainExecutableMH, mainExecutableSlide, sExecPath);
        sMainExecutable->setNeverUnload();
        // 2.6设置链接(就是我们上面第三步提到的链接)的上下文,设置其 mainExecutable 为上文中的
        gLinkContext.mainExecutable = sMainExecutable;
        // load shared cache
        // 2.7加载共享缓存
        checkSharedRegionDisable();
    #if DYLD_SHARED_CACHE_SUPPORT
        if ( gLinkContext.sharedRegionMode != ImageLoader::kDontUseSharedRegion )
            mapSharedCache();
    #endif
        // load any inserted libraries
        // 2.8加载插入的动态库,动态库的镜像全部加入`sAllImages`数组中
        // `DYLD_INSERT_LIBRARIES` 是环境变量, 调用`loadInsertedDylib`方法加载所有要插入的库
        if  ( sEnv.DYLD_INSERT_LIBRARIES != NULL ) {
            for (const char* const* lib = sEnv.DYLD_INSERT_LIBRARIES; *lib != NULL; ++lib) 
                loadInsertedDylib(*lib);
        }
        // record count of inserted libraries so that a flat search will look at 
        // inserted libraries, then main, then others.
        // 2.9记录插入的动态库的数量,方便去查找这些库
        sInsertedDylibCount = sAllImages.size()-1;
        
#pragma mark 3.对可执行文件和相关动态库进行链接--用于修证符号指针,使其指向正确地址。

        // link main executable
        // 3.1链接主程序可执行文件
        gLinkContext.linkingMainExecutable = true;
        link(sMainExecutable, sEnv.DYLD_BIND_AT_LAUNCH, ImageLoader::RPathChain(NULL, NULL));
        gLinkContext.linkingMainExecutable = false;
        if ( sMainExecutable->forceFlat() ) {
            gLinkContext.bindFlat = true;
            gLinkContext.prebindUsage = ImageLoader::kUseNoPrebinding;
        }
        // 3.2返回主程序`mian()`函数的入口地址
        result = (uintptr_t)sMainExecutable->getMain();
        
        // link any inserted libraries
        // do this after linking main executable so that any dylibs pulled in by inserted 
        // dylibs (e.g. libSystem) will not be in front of dylibs the program uses
        // 3.3链接插入的动态库(除了主程序)
        // 在链接主可执行文件之后执行此操作,以保证动态库都在主程序后面被链接
        if ( sInsertedDylibCount > 0 ) {
            for(unsigned int i=0; i < sInsertedDylibCount; ++i) {
                ImageLoader* image = sAllImages[i+1];
                link(image, sEnv.DYLD_BIND_AT_LAUNCH, ImageLoader::RPathChain(NULL, NULL));
            }
        }
        
    #if SUPPORT_OLD_CRT_INITIALIZATION
        // Old way is to run initializers via a callback from crt1.o
        if ( ! gRunInitializersOldWay ) 
    #endif
#pragma mark 4.递归的对相关动态库进行初始化,对可执行文件初始化,这个过程中会注册Objc类;把category定义的各种方法、属性、协议等加入类的数组中;调用各个类的load方法。
        // 执行初始化方法
        // 其中`+load` 和constructor方法就是在这里执行
        initializeMainExecutable(); // run all initializers
    }
    catch(const char* message) {
        halt(message);
    }
    catch(...) {
        dyld::log("dyld: launch failed\n");
    }
#pragma mark 5.dyld返回一个主程序的main()函数,开始执行main()函数。
    //返回主程序`main()`函数地址
    return result;
}

2.dyld先设置一下运行环境,配置环境变量,然后加载可执行文件和依赖动态库。

刚开始先是为上下文和运行环境进行了大量的配置,以保证后面加载镜像、符号链接、初始化等工作的正常进行,上面代码都有注释,就不再说了。

  • 加载主程序

这一步主要是在instantiateFromLoadedImage()函数中执行,首先在isCompatibleMachO()函数中判断可执行文件与当前机型的兼容性,如果兼容接下来就调用ImageLoaderMachO()实例化一个主程序,紧接着调用addImage()函数把主程序镜像添加到sAllImages数组中,这些都是在instantiateFromLoadedImage()函数中完成,这个函数会把实例化的主程序做为返回值返回给sMainExecutable
之后符号链接上下文中的mainExecutable设置为刚刚我们实例化出来的主程序--sMainExecutable

  • 加载共享缓存

这一步主要是在mapSharedCache()函数中映射共享缓存,首先通过_shared_region_check_np()快速检查缓存是否已真正映射到共享区域,如果已经映射了, 就更新共享缓存。
反之, 判断系统是否处于安全启动模式(safe-boot mode)下,如果我们处于安全启动模式,并且在此启动周期中未创建缓存,就删除缓存文件并重新生成它。
接下来调用openSharedCacheFile()打开缓存文件, 接着读取缓存文件的前4096字节,解析缓存头dyld_cache_header的信息, 将解析好的缓存信息存入mappings变量,最后调用_shared_region_map_np完成真正的映射工作,并在sSharedCache记录dyld_cache_header头文件。
如果共享缓存创建成功,就在符号链接上下文中记录dyld地址与共享缓存地址是否相同,然后告诉gdb共享缓存在哪。

  • 加载插入的动态库

首先循环遍历DYLD_INSERT_LIBRARIES环境变量中指定的动态库,然后调用loadInsertedDylib()函数将其加载,在该函数中先配置加载上下文环境,再调用load()函数进行加载。load()函数会调用loadPhase0()函数,loadPhase0()会尝试从所有的可能路径加载动态库,直到loadPhase6()函数,查找的顺序为DYLD_ROOT_PATH->LD_LIBRARY_PATH->DYLD_FRAMEWORK_PATH->原始路径->DYLD_FALLBACK_LIBRARY_PATH,找到后调用ImageLoaderMachO()函数来实例化一个ImageLoader,之后调用checkandAddImage()验证镜像并将其加入到全局镜像数组sAllImages中。
如果image为空,表示没有找到动态库,则会抛出异常。

3.对可执行文件和相关动态库进行链接--用于修证符号指针,使其指向正确地址。

  • 链接主程序

首先设置链接上下文的linkingMainExecutable参数为true,然后调用link()函数完成主程序的链接操作,该函数经过一系列的检查,之后调用了ImageLoader::link()函数,该函数中调用recursiveLoadLibraries()函数递归加载所依赖的动态库。
recursiveLoadLibraries()函数中首先找到依赖库的数量、名称、版本等等信息,然后根据这些信息调用LinkContextloadLibrary()函数去加载这些动态库,然后获取这些动态库信息,判断版本信息是否兼容,如果不兼容就抛出异常。
依赖库递归加载完成之后会进行一个排序,以便后续的工作都是从最低级动态库开始,然后就开始真正的链接工作,先是调用recursiveRebase()函数对动态库进行基地址的复位,然后调用
recursiveBind()函数对动态库进行non-lazy符号绑定,一般的情况下多数符号都是lazy的,他们在第一次使用的时候才进行绑定。
主程序链接完成之后就可以得到主程序入口函数,赋值给result,待所有工作完成之后,把result做为返回值返回。

  • 链接插入的动态库

和主程序链接流程是一样的。

4.递归的对相关动态库进行初始化,对可执行文件初始化,这个过程中会注册Objc类;把category定义的各种方法、属性、协议等加入类的数组中;调用各个类的load方法。

首先对link上下文进行一个设置,记录一下我们当前进度;然后对除了主程序之外的动态库进行初始化;然后再单独对主程序进行初始化;最后注册atexit()以在进程退出时加载所有镜像的终止符。

  • 初始化动态库

经过initializeMainExecutable()->ImageLoader::runInitializers()->ImageLoader::recursiveInitialization()一系列的调用进入recursiveInitialization ()函数,再此函数中,首先自底向上的递归初始化依赖库,然后发出通知,当前状态发生变化--动态库的依赖已经完全加载;然后才是初始化当前动态库--调用doInitialization ()函数,在这个函数中会调用函数的"Initializer"符号,实际上就是镜像内部的真正初始化函数;然后继续发出通知,当前镜像状态发生变化--动态库本身已经初始化完成。

initializeMainExecutable()的过程

在前面初始化动态库一小节说过,doInitialization ()函数会调用doImageInit()doModInitFunctions()函数,而在这两个函数中调用的镜像的Initializer方法,这个Initializer只是一个符号指向,并不是指一个名字为Initializer的方法,而是C++静态对象初始化构造器,atribute((constructor))进行修饰的方法,在镜像中Initializer指针所指向该初始化方法的地址。
我们可以通过一个环境变量来查看我们调用的各个依赖库的Initializer方法,Edit Scheme -> Run ->Arguments中增加一个DYLD_PRINT_INITIALIZERS的环境变量,设置值为YES,然后运行我们的程序,可以在控制台看到动态库Initializer调用名称。

环境变量

dyld: calling initializer function 0x180feba94 in /usr/lib/libSystem.B.dylib
dyld: calling -init function 0x10616cd38 in /Developer/usr/lib/libBacktraceRecording.dylib
dyld: calling initializer function 0x181003620 in /usr/lib/libc++.1.dylib
dyld: calling -init function 0x181dd1be4 in /System/Library/Frameworks/CoreFoundation.framework/CoreFoundation
...
dyld: calling initializer function 0x10650884c in /Developer/Library/PrivateFrameworks/GPUTools.framework/libglInterpose.dylib
dyld: calling initializer function 0x1068636b8 in /Developer/Library/PrivateFrameworks/MTLToolsDeviceSupport.framework/libMTLInterpose.dylib

我们可以看到一个巨长的调用信息,我们可以看到每个Image都有不同的初始化方法名称。
我们看到最开始调用的是一个名字为libSystem.dylib动态库,就是在这个动态库中,调用了libdispatch进行了初始化,在libdispatch源码中我们看到到经过libdispatch_init()->_os_object_init()->_objc_init()一系列的调用_objc_init()进行了runtime的初始化,我们可以在工程中打上符号断点进行验证:

符号断点

运行程序,查看函数调用栈(Xcode显示不完整,利用lldb查看):

frame #0: 0x0000000190942350 libobjc.A.dylib`_objc_init
frame #1: 0x0000000103700238 libdispatch.dylib`_os_object_init + 16
frame #2: 0x000000010370ebc4 libdispatch.dylib`libdispatch_init + 372
frame #3: 0x00000001908bcb04 libSystem.B.dylib`libSystem_initializer + 136
frame #4: 0x0000000102d410e0 dyld`ImageLoaderMachO::doModInitFunctions(ImageLoader::LinkContext const&) + 412
frame #5: 0x0000000102d41314 dyld`ImageLoaderMachO::doInitialization(ImageLoader::LinkContext const&) + 36
frame #6: 0x0000000102d3c398 dyld`ImageLoader::recursiveInitialization(ImageLoader::LinkContext const&, unsigned int, char const*, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&) + 464
frame #7: 0x0000000102d3c328 dyld`ImageLoader::recursiveInitialization(ImageLoader::LinkContext const&, unsigned int, char const*, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&) + 352
frame #8: 0x0000000102d3b3dc dyld`ImageLoader::processInitializers(ImageLoader::LinkContext const&, unsigned int, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&) + 136
frame #9: 0x0000000102d3b498 dyld`ImageLoader::runInitializers(ImageLoader::LinkContext const&, ImageLoader::InitializerTimingList&) + 84
frame #10: 0x0000000102d2a688 dyld`dyld::initializeMainExecutable() + 140
frame #11: 0x0000000102d2f2a0 dyld`dyld::_main(macho_header const*, unsigned long, int, char const**, char const**, char const**, unsigned long*) + 4304
frame #12: 0x0000000102d29044 dyld`_dyld_start + 68

runtime初始化后在_objc_init中注册了几个通知,然后初始化相应依赖库里的类结构,调用依赖库里所有的load方法。
以我们的主程序镜像为例,它的initializer方法是最后调用的,当initializer方法被调用前,dyld会通知runtime进行类结构初始化,然后再通知调用load方法,这些目前还发生在main函数前,但由于lazy bind机制,依赖库多数都是在使用时才进行绑定,所以这些依赖库的类结构初始化都是发生在程序里第一次使用到该依赖库时才进行的。

  • 返回APP的main()函数地址

在第二步的链接主程序中,我们说过主程序(最后一个动态库)链接完成之后,就通过getMain()函数拿到了主程序main()地址,到这一步直接把拿到的result返回就可以了。

启动优化结论

根据以上过程,我们可以找到我们程序在启动过程中可以优化的点:

  1. 减少依赖不必要的库,不管是动态库还是静态库;如果可以的话,把动态库改造成静态库;如果必须依赖动态库,则把多个非系统的动态库合并成一个动态库;
  2. 检查下framework应当设为optionalrequired,如果该framework在当前App支持的所有iOS系统版本都存在,那么就设为required,否则就设为optional,因为`optional``会有些额外的检查;
  3. 合并或者删减一些OC类和函数;关于清理项目中没用到的类,使用工具AppCode代码检查功能,查到当前项目中没有用到的类(也可以用根据linkmap文件来分析,但是准确度不算很高)。
  4. 删减一些无用的静态变量,
  5. 删减没有被调用到或者已经废弃的方法。
  6. 将不必须在+load方法中做的事情延迟到+initialize中,尽量不要用C++虚函数(创建虚函数表有开销)
  7. 类和方法名不要太长:iOS每个类和方法名都在__cstring段里都存了相应的字符串值,所以类和方法名的长短也是对可执行文件大小是有影响的;因还是object-c的动态特性,因为需要通过类/方法名反射找到这个类/方法进行调用,object-c对象模型会把类/方法名字符串都保存下来;
  8. 用dispatch_once()代替所有的 attribute((constructor)) 函数、C++静态对象初始化、ObjC的+load函数;
  9. 在设计师可接受的范围内压缩图片的大小,会有意外收获。压缩图片为什么能加快启动速度呢?因为启动的时候大大小小的图片加载个十来二十个是很正常的,图片小了,IO操作量就小了,启动当然就会快了,比较靠谱的压缩算法是TinyPNG。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容