Cocos2dx源码赏析(1)之启动流程与主循环

我们知道Cocos2dx是一款开源的跨平台游戏引擎,而学习开源项目一个较实用的办法就是读源码。所谓,“源码之前,了无秘密”。而笔者从事的也是游戏开发工作,因此,通过梳理下源码的脉络,来加深对Cocos2dx游戏引擎的理解。

既然,Cocos2dx是跨平台的,那么,就有针对不同平台运行的入口以及维持引擎运转的“死循环”。下面,就分别从Windows、Android、iOS三个平台说明下Cocos2dx从启动到进入主循环的过程。

1、Windows

以引擎下的cpp-empty-test项目工程为例:

Windows工程的入口函数为cpp-empty-test/win32/main.cpp中的_tWinMain函数。

int WINAPI _tWinMain(HINSTANCE hInstance,
HINSTANCE hPrevInstance,
LPTSTR lpCmdLine,
int nCmdShow)
{
UNREFERENCED_PARAMETER(hPrevInstance);
UNREFERENCED_PARAMETER(lpCmdLine); // create the application instance
AppDelegate app;
return Application::getInstance()->run();
}

这里,定义了一个AppDelegate类型的栈对象app。而AppDelegate继承自Application,所以这里会先初始化父类Application。再看下Application的实现,注意是进到CCApplication-win32.h和CCApplication-win32.cpp里。当然,Application还继续继承自ApplicationProtocol(通过预处理宏来针对不同的平台执行不同的代码)。这里,并没有做什么特别的处理,只是作了下相应的初始化的工作。

而在CCApplication-win32.h和CCApplication-win32.cpp代码中都有这样的宏判断:

#if CC_TARGET_PLATFORM == CC_PLATFORM_WIN32

继续追踪下去,可以发现CC_PLATFORM_WIN32在定义了WIN32宏时定义。

接下来,便执行Application::getInstance()->run()代码,这里的Application为单例的实现,这也是Cocos2dx单例常用的实现方式,在2.x版本的引擎中,单例的实现为sharedApplication,这是仿照Objective-C的写法。继续看CCApplication-win32中的run方法:

int Application::run()
{
PVRFrameEnableControlWindow(false); // Main message loop:
LARGE_INTEGER nLast;
LARGE_INTEGER nNow; QueryPerformanceCounter(&nLast); initGLContextAttrs(); // Initialize instance and cocos2d.
if (!applicationDidFinishLaunching())
{
return 1;
} auto director = Director::getInstance();
auto glview = director->getOpenGLView(); // Retain glview to avoid glview being released in the while loop
glview->retain(); while(!glview->windowShouldClose())
{
QueryPerformanceCounter(&nNow);
if (nNow.QuadPart - nLast.QuadPart > _animationInterval.QuadPart)
{
nLast.QuadPart = nNow.QuadPart - (nNow.QuadPart % _animationInterval.QuadPart); director->mainLoop();
glview->pollEvents();
}
else
{
Sleep(1);
}
} // Director should still do a cleanup if the window was closed manually.
if (glview->isOpenGLReady())
{
director->end();
director->mainLoop();
director = nullptr;
}
glview->release();
return 0;
}

这里主要先看下applicationDidFinishLaunching()的调用,applicationDidFinishLaunching是虚函数,这里会调到子类AppDelegate中的applicationDidFinishLaunching的实现:

bool AppDelegate::applicationDidFinishLaunching()
{
auto director = Director::getInstance();
auto glview = director->getOpenGLView();
if(!glview) {
glview = GLViewImpl::create("Cpp Empty Test");
director->setOpenGLView(glview);
} director->setOpenGLView(glview); glview->setDesignResolutionSize(designResolutionSize.width, designResolutionSize.height, ResolutionPolicy::NO_BORDER); director->setAnimationInterval(1.0f / 60); auto scene = HelloWorld::scene(); director->runWithScene(scene); return true;
}

这里对代码做了适当的删减。可以看到在AppDelegate的applicationDidFinishLaunching主要做了些跟游戏初始化相关的处理。例如,初始化导演类,设置OpenGL视图,设置适配方式,设置帧率以及初始化场景和运行该场景等。基本这个方法,也可以当作我们游戏代码初始化的入口。

再回到CCApplication的run方法,继续往下看。这里,有个while循环,至此,就找到了引擎的“死循环”了。在这个循环中,调用了导演类的mainLoop主循环方法,而在mainLoop中,主要控制渲染,定时器,动画,事件循环等处理。后续会分析这相关的部分,这里就不过多介绍了。至此,就是Cocos2dx在Windows平台从启动到主循环,代码执行的流程,简单的梳理,可以知道引擎代码是如何架构的。

2、Android

在Android平台的应用,一般由多个Activity组成,一个Activity代表一个“窗口”,Activity根据应用前后台切换有对应的声明周期状态。在配置清单文件中声明了

<action android:name="android.intent.action.MAIN" />

即代表该Acitivity为应用的入口Activity。而入口Activity也一般称为闪屏页(Splash)或启动页,用来呈现公司的或运营的合作伙伴Logo,之后再切换到主Activity。在Cocos2dx游戏中,主Activity一般是继承Cocos2dx引擎封装的Cocos2dxActivity类。先看onCreate()方法:

protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState); onLoadNativeLibraries(); sContext = this; Cocos2dxHelper.init(this); this.init();
}

对onCreate里的代码做了精简,只列举了比较重要的几个方法。首先onLoadNativeLibraries方法:

protected void onLoadNativeLibraries() {
try {
ApplicationInfo ai = getPackageManager().getApplicationInfo(getPackageName(), PackageManager.GET_META_DATA);
Bundle bundle = ai.metaData;
String libName = bundle.getString("android.app.lib_name");
System.loadLibrary(libName);
} catch (Exception e) {
e.printStackTrace();
}
}

该方法会读取配置在Manifest里中的meta-data标签的字段为android.app.lib_name的值,来加载动态库。即为:

    <meta-data android:name="android.app.lib_name"
android:value="cpp_empty_test" />

同样,以cpp_empty_test的项目为例,可知这里要加载名字为libcpp_empty_test.so动态库。由于Cocos2dx引擎核心部分是C++实现,在Android平台通过jni的方式来调用和启动引擎。

再回到Cocos2dxActivity中的onCreate,继续往下进行。可以看到:

sContext = this;

sContext是Cocos2dxActivity的实例,被声明为静态的,通过这种实现了单例的效果。在需要Context实例的地方以及需要调用Cocos2dxActivity方法的地方,可以直接用该实例。

Cocos2dxHelper.init(this);

Cocos2dxHelper的init中主要是一些对象的初始化,例如:声音,音效,重力感应,Asset管理等。

接下来,调用了Cocos2dxActivity的init方法里:

public void init() {

        ViewGroup.LayoutParams framelayout_params =
new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT); mFrameLayout = new ResizeLayout(this); mFrameLayout.setLayoutParams(framelayout_params); ViewGroup.LayoutParams edittext_layout_params =
new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
Cocos2dxEditBox edittext = new Cocos2dxEditBox(this);
edittext.setLayoutParams(edittext_layout_params); mFrameLayout.addView(edittext); this.mGLSurfaceView = this.onCreateView(); mFrameLayout.addView(this.mGLSurfaceView); // Switch to supported OpenGL (ARGB888) mode on emulator
if (isAndroidEmulator())
this.mGLSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 16, 0); this.mGLSurfaceView.setCocos2dxRenderer(new Cocos2dxRenderer());
this.mGLSurfaceView.setCocos2dxEditText(edittext); setContentView(mFrameLayout);
}

该方法主要设置要显示的视图界面,即mFrameLayout。重点关注这几行代码:

        this.mGLSurfaceView = this.onCreateView();
mFrameLayout.addView(this.mGLSurfaceView); this.mGLSurfaceView.setCocos2dxRenderer(new Cocos2dxRenderer());

在onCreateView方法中,返回了一个Cocos2dxGLSurfaceView对象,并将该对象添加到了帧布局的容器对象(mFrameLayout)中。首先,了解下Cocos2dxGLSurfaceView类的实现:

public class Cocos2dxGLSurfaceView extends GLSurfaceView {

    private Cocos2dxRenderer mCocos2dxRenderer;

    public void setCocos2dxRenderer(final Cocos2dxRenderer renderer) {
this.mCocos2dxRenderer = renderer;
this.setRenderer(this.mCocos2dxRenderer);
} public void onResume() {
super.onResume();
this.setRenderMode(RENDERMODE_CONTINUOUSLY);
this.queueEvent(new Runnable() {
public void run() {
Cocos2dxGLSurfaceView.this.mCocos2dxRenderer.handleOnResume();
}
});
} public void onPause() {
this.queueEvent(new Runnable() {
public void run() {
Cocos2dxGLSurfaceView.this.mCocos2dxRenderer.handleOnPause();
}
});
this.setRenderMode(RENDERMODE_WHEN_DIRTY);
//super.onPause();
}
}

(上述代码有做删减,只保留需要说明的地方)

Cocos2dxGLSurfaceView继承自GLSurfaceView,通过阅读GLSurfaceView文档可知,GLSurfaceView又继承自SurfaceView,而SurfaceView又进一步继承自View。GLSurfaceView封装了OpenGL ES所需的运行环境,同时能让OpenGL ES渲染的内容直接生成在Android的View视图上。绘制渲染时,用户可以自定义渲染器(GLSurfaceView.Renderer),该渲染器运行在单独的线程里,独立于UI线程。GLSurfaceView还能适应于Activity的声明周期的变化做相应的处理(例如:onPause、onResume等)。

GLSurfaceView的初始化过程中,需要设置渲染器。即调用setRenderer方法。

Cocos2dxGLSurfaceView类中的onResume和onPause方法,这两个方法受Activity的相应的声明周期的方法影响, Activity窗口暂停(pause)或恢复(resume)时,GLSurfaceView都会收到通知,此时它的onPause方法和 onResume方法应该被调用。这样GLSurfaceView就会暂停或恢复它的渲染线程,以便它及时释放或重建OpenGL的资源。其中都分别调用了queueEvent的方法。这里,需要注意的是,Android的UI运行在主线程,而OpenGL的GLSurfaceView运行在一个单独的线程中,因此,需要调用queueEvent来给OpenGL线程分发调用,来达到两个线程间通信。最后都交给Cocos2dxRenderer处理。

最后,再重点看下渲染器类Cocos2dxRenderer的实现:

public class Cocos2dxRenderer implements GLSurfaceView.Renderer {

    public void onSurfaceCreated(final GL10 GL10, final EGLConfig EGLConfig) {
Cocos2dxRenderer.nativeInit(this.mScreenWidth, this.mScreenHeight);
this.mLastTickInNanoSeconds = System.nanoTime();
mNativeInitCompleted = true;
} public void onSurfaceChanged(final GL10 GL10, final int width, final int height) {
Cocos2dxRenderer.nativeOnSurfaceChanged(width, height);
} public void onDrawFrame(final GL10 gl) {
if (sAnimationInterval <= 1.0 / 60 * Cocos2dxRenderer.NANOSECONDSPERSECOND) {
Cocos2dxRenderer.nativeRender();
} else {
final long now = System.nanoTime();
final long interval = now - this.mLastTickInNanoSeconds; if (interval < Cocos2dxRenderer.sAnimationInterval) {
try {
Thread.sleep((Cocos2dxRenderer.sAnimationInterval - interval) / Cocos2dxRenderer.NANOSECONDSPERMICROSECOND);
} catch (final Exception e) {
}
} this.mLastTickInNanoSeconds = System.nanoTime();
Cocos2dxRenderer.nativeRender();
}
}
}

首先,Cocos2dxRenderer继承了渲染器类GLSurfaceView.Renderer,并重写了以下上个方法:

onSurfaceCreated

该方法是当Surface被创建的时候,会调用,即应用程序第一次运行的时候。当设备被唤醒或用户从其它Activity切换回来的时候,该方法也可能被调用。因此,该方法可能会被多次调用。一般会在该方法中,完成一些OpenGL ES的初始化工作。

onSurfaceChanged

该方法是在Surface被创建以后,每次Surface尺寸发生变化时(例如:横竖屏切换),该方法会被调用。

onDrawFrame

绘制的每一帧,该方法都会被调用。

其实,看到onDrawFrame中的代码,可以知道Cocos2dx引擎在Android平台的“死循环”在该方法中。最后,通过jni的方式调用nativeRender来启动导演类的主循环。

熟悉jni调用的可以知道,nativeRender是声明为native的方法,Cocos2dxRenderer.nativeRender最终会调到Java_org_cocos2dx_lib_Cocos2dxRenderer.cpp类中:

JNIEXPORT void JNICALL Java_org_cocos2dx_lib_Cocos2dxRenderer_nativeRender(JNIEnv* env) {
cocos2d::Director::getInstance()->mainLoop();
}

可以看到,跟Windows平台的一样,最终调用到导演类的mainLoop方法,殊途同归。以上便是,Android平台Cocos2dx引擎从启动到进入死循环的过程。

3、iOS

同样,以引擎下的cpp-empty-test项目工程为例:

iOS工程的入口函数为cpp-empty-test/proj.ios/main.cpp中的main函数。

int main(int argc, char *argv[]) {
NSAutoreleasePool * pool = [[NSAutoreleasePool alloc] init];
int retVal = UIApplicationMain(argc, argv, nil, @"AppController");
[pool release];
return retVal;
}

在iOS应用中,都必须在函数main中调用UIApplicationMain方法来启动应用和设置相应的事件循环。UIApplicationMain函数原型如下:

UIKIT_EXTERN int UIApplicationMain(int argc, char *argv[], NSString * __nullable principalClassName, NSString * __nullable delegateClassName);

其中,argc是参数的个数,argv是可变的参数列表,principalClassName代表的是一个继承自UIApplication类的类名,delegateClassName是应用程序的代理类名称。在跟踪AppController类代码之前,有必要先了解下iOS应用的运行状态以及相应的生命周期方法:

  • Not Running(非运行状态):应用没有运行或被系统终止。
  • Inactive(前台非活动状态):应用正在进入前台状态,但是还不能接受事件处理。
  • Active(前台活动状态):应用进入前台,能接受事件处理。
  • Background(后台状态):应用进入后台后,依然能够执行代码。如果有可执行的代码,就会执行,如果没有可执行的代码或可执行的代码执行完毕,应用会马上进入挂起状态。
  • Suspended(挂起状态):处于挂起的应用进入一种“冷冻”状态,不能执行代码。如果系统内存不够,应用会被终止。

生命周期方法有:

application:didFinishLaunchingWithOptions:

应用程序启动并进行初始化时会调用该方法。

applicationDidBecomeActive:

应用程序进入前台并处于活动状态时调用该方法。

applicationWillResignActive:

应用程序从活动状态进入非活动状态时调用该方法。

applicationDidEnterBackground:

应用程序进入后台时调用该方法。

applicationWillEnterForeground:

应用程序进入前台,但还没有处于活动状态时调用该方法。

applicationWillTerminate:

应用程序被终止时调用该方法。

进入AppController类,AppController实现了UIApplicationDelegate,并重写了相应的生命周期的方法。那么,重点看application:didFinishLaunchingWithOptions:方法:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {    

    cocos2d::Application *app = cocos2d::Application::getInstance();
app->initGLContextAttrs();
cocos2d::GLViewImpl::convertAttrs(); // Override point for customization after application launch. // Add the view controller's view to the window and display.
window = [[UIWindow alloc] initWithFrame: [[UIScreen mainScreen] bounds]]; CCEAGLView *eaglView = [CCEAGLView viewWithFrame: [window bounds]
pixelFormat: (NSString*)cocos2d::GLViewImpl::_pixelFormat
depthFormat: cocos2d::GLViewImpl::_depthFormat
preserveBackbuffer: NO
sharegroup: nil
multiSampling: NO
numberOfSamples: 0]; // Use RootViewController manage CCEAGLView
viewController = [[RootViewController alloc] initWithNibName:nil bundle:nil];
viewController.wantsFullScreenLayout = YES;
viewController.view = eaglView; // Set RootViewController to window
if ( [[UIDevice currentDevice].systemVersion floatValue] < 6.0)
{
// warning: addSubView doesn't work on iOS6
[window addSubview: viewController.view];
}
else
{
// use this method on ios6
[window setRootViewController:viewController];
} [window makeKeyAndVisible]; [[UIApplication sharedApplication] setStatusBarHidden: YES]; // IMPORTANT: Setting the GLView should be done after creating the RootViewController
cocos2d::GLViewImpl *glview = cocos2d::GLViewImpl::createWithEAGLView(eaglView);
cocos2d::Director::getInstance()->setOpenGLView(glview); app->run();
return YES;
}

这里主要是实例化一个UIWindow对象,每一个UIWindow对象上面都有一个根视图,它所对应的控制器为根视图控制器(ViewController),最后把根视图控制器放到UIWindow上。最后,app->run()会调用到CCApplication-ios.mm(这个也是根据项目中的预编译宏实现)中的run方法:

int Application::run()
{
if (applicationDidFinishLaunching())
{
[[CCDirectorCaller sharedDirectorCaller] startMainLoop];
}
return 0;
}

这里有个跟生命周期方法类似的名字applicationDidFinishLaunching,这个会调到ApAppDelegate的applicationDidFinishLaunching方法,这点跟Windows平台类似,一般是在这个方法做跟游戏内容相关的初始化。run方法接下来,就是调startMainLoop方法,看这个名字,知道跟要找的目标很接近了,再继续跟下去。这里会调到CCDirectorCaller-ios.mm中的startMainLoop方法:

-(void) startMainLoop
{
// Director::setAnimationInterval() is called, we should invalidate it first
[self stopMainLoop]; displayLink = [NSClassFromString(@"CADisplayLink") displayLinkWithTarget:self selector:@selector(doCaller:)];
[displayLink setFrameInterval: self.interval];
[displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
}

首先是通过NSClassFromString动态加载CADisplayLink类,然后调用了该类的displayLinkWithTarget方法,该方法类似定时器的功能,周期的调用该selector包装的方法(即:doCaller:方法):

-(void) doCaller: (id) sender
{
if (isAppActive) {
cocos2d::Director* director = cocos2d::Director::getInstance();
[EAGLContext setCurrentContext: [(CCEAGLView*)director->getOpenGLView()->getEAGLView() context]];
director->mainLoop();
}
}

至此,我们就找到了导演类的mainLoop方法,开启了引擎的主循环。以上,便是Cocos2dx引擎在iOS平台从启动到进入主循环的过程。

通过以上简单的分析,我们知道,Cocos2dx引擎利用了相应的平台循环方式来调用导演类的主循环来进入引擎的内部工作。下一篇继续通过代码的方式来梳理下Cocos2dx的渲染过程。如果在本篇中,有你觉得不对的地方,也欢迎来和我讨论。

技术交流QQ群:528655025

作者:AlphaGL

出处:http://www.cnblogs.com/alphagl/

版权所有,欢迎保留原文链接进行转载

Cocos2dx源码赏析(1)之启动流程与主循环的更多相关文章

  1. Cocos2dx源码赏析(4)之Action动作

    Cocos2dx源码赏析(4)之Action动作 本篇,依然是通过阅读源码的方式来简单赏析下Cocos2dx中Action动画的执行过程.当然,这里也只是通过这种方式来总结下对Cocos2dx引擎的理 ...

  2. Cocos2dx源码赏析(2)之渲染

    Cocos2dx源码赏析(2)之渲染 这篇,继续从源码的角度来跟踪下Cocos2dx引擎的渲染过程,以此来梳理下Cocos2dx引擎是如何将精灵等元素显示在屏幕上的. 从上一篇对Cocos2dx启动流 ...

  3. Cocos2dx源码赏析(3)之事件分发

    Cocos2dx源码赏析(3)之事件分发 这篇,继续从源码的角度赏析下Cocos2dx引擎的另一模块事件分发处理机制.引擎的版本是3.14.同时,也是学习总结的过程,希望通过这种方式来加深对Cocos ...

  4. JVM源码分析之JVM启动流程

      原创申明:本文由公众号[猿灯塔]原创,转载请说明出处标注 “365篇原创计划”第十四篇. 今天呢!灯塔君跟大家讲: JVM源码分析之JVM启动流程 前言: 执行Java类的main方法,程序就能运 ...

  5. Tomcat源码分析之—具体启动流程分析

    从Tomcat启动调用栈可知,Bootstrap类的main方法为整个Tomcat的入口,在init初始化Bootstrap类的时候为设置Catalina的工作路径也就是Catalina_HOME信息 ...

  6. Android进阶系列之源码分析Activity的启动流程

    美女镇楼,辟邪! 源码,是一个程序猿前进路上一个大的而又不得不去翻越障碍,我讨厌源码,看着一大堆.5000多行,要看完得啥时候去了啊.不过做安卓的总有这一天,自从踏上这条不归路,我就认命了.好吧,我慢 ...

  7. ASP.NET Core MVC 源码学习:MVC 启动流程详解

    前言 在 上一篇 文章中,我们学习了 ASP.NET Core MVC 的路由模块,那么在本篇文章中,主要是对 ASP.NET Core MVC 启动流程的一个学习. ASP.NET Core 是新一 ...

  8. [Abp vNext 源码分析] - 1. 框架启动流程分析

    一.简要说明 本篇文章主要剖析与讲解 Abp vNext 在 Web API 项目下的启动流程,让大家了解整个 Abp vNext 框架是如何运作的.总的来说 ,Abp vNext 比起 ABP 框架 ...

  9. Jvm(jdk8)源码分析1-java命令启动流程详解

    JDK8加载源码分析 1.概述 现在大多数互联网公司都是使用java技术体系搭建自己的系统,所以对java开发工程师以及java系统架构师的需求非常的多,虽然普遍的要求都是需要熟悉各种java开发框架 ...

随机推荐

  1. centos下mysqlreport安装和使用

    首先查看你的机器是否安装了perl: #perl -v 显示版本号即表示已安装 然后: #yum install perl-DBD-mysql perl-DBI #yum install mysqlr ...

  2. java中抽象的(abstract)方法是否可同时是静态的(static),是否可同时是本地方法(native),是否可同时被synchronized修饰

    1.abstract与static what abstract:用来声明抽象方法,抽象方法没有方法体,不能被直接调用,必须在子类overriding后才能使用. static:用来声明静态方法,静态方 ...

  3. centos7 支持中文显示(转)

    centos7 支持中文显示 - kingleoric - 博客园https://www.cnblogs.com/kingleoric/p/7517753.html http://www.linuxi ...

  4. 使用iometer测试

    对国产机进行测试 1.win7上安装测试 下载: 点击打开链接 双击安装即可. 2.ubuntu下配置: OS: Ubuntu 12.04LTS x86_64Kernel: 3.5.0-26-gene ...

  5. #leetcode刷题之路48-旋转图像

    给定一个 n × n 的二维矩阵表示一个图像.将图像顺时针旋转 90 度.说明:你必须在原地旋转图像,这意味着你需要直接修改输入的二维矩阵.请不要使用另一个矩阵来旋转图像.示例 1:给定 matrix ...

  6. .NET中Quartz任务调度器的简单应用实例

    1.首先从NuGet中安装Quartz,安装最新版本就OK 2.新建一个Job类实现Quart中的IJob接口用于执行业务逻辑,代码如下: class CheckUpdateJob : IJob { ...

  7. Unix中Signal信号的不同

    Unix系统signal函数的不同 (1)函数说明 在signal函数中,有两个形参,分别代表需要处理的信号编号值和处理信号函数的指针.它主要是用于前32种非实时信号的处理,不支持信号的传递信息.但是 ...

  8. linux中服务环境的搭建

    一.Samba服务 samba服务的安装及配置: sudo apt-get install samba 二.配置: 1.创建一个需要共享的目录,并修改权限: lpf@ubuntu:~$ mkdir l ...

  9. uva 1590 - IP Networks(IP地址)

    习题4-5 IP网络(IP Networks, ACM/ICPC NEERC 2005, UVa1590) 可以用一个网络地址和一个子网掩码描述一个子网(即连续的IP地址范围).其中子网 掩码包含32 ...

  10. GATK--数据预处理,质控,检测变异

    版权声明:本文源自 解螺旋的矿工, 由 XP 整理发表,共 13781 字. 转载请注明:从零开始完整学习全基因组测序(WGS)数据分析:第4节 构建WGS主流程 | Public Library o ...