0%

为什么要使用CMake

嗯,考虑到能看到这里的基本都会对CMake 有一定了解,所以先不讲这东西是啥,问题是为啥要用这东西。

我们这边有一些需求,目的是基础功能保持稳定性,又因为涉及到多个类型的客户端(IOS、Android、Windows、Mac),那么共用的内容C++使用就是一种必然,同时考虑到后期的可维护性,CMake 的使用就应该是一种必然。

因为是要做公共基础内容,最直接的思考是提供静态库给各方使用,这也就间接回答了CMake 的用途,用来把源码生成二进制文件。

按照正常的流程,先给一个开胃菜,提供一个Demo。

推荐一个软件 CLine

CLineDemo

刚好能够提供C++ 静态库和动态库。

因为我需要优先做Android 的内容,所以我一步到位直接使用了AndroidStudio。

AndroidCmake

上面是简单的创建工程,咱们接着往下面。

以CLine 为例子

ClineCMake

CMake 文本的写法就是 需要设置 CMake version 然后给出一个 project(name) name 为生成静态库名字 ,设置C11 ,然后就是给这个project 添加需要编译的CPP文件

1
2
3
4
├── CMakeLists.txt
├── build
├── library.cpp
└── library.h

文件结构大致是这样,需要有**source file ** 、 build 文件夹(我不太喜欢CLine 默认的 cmake-build-debug,自己更改了)、和必需品 CMakeLists.txt

不依赖工具,可以在终端进入到当前文件夹下面的build 文件夹 然后执行 cmake ..

执行cmake 命令之后,需要生成库文件需要在当前命令行再次执行 make , 等命令执行完毕,库文件就会出现在build 文件夹。

依靠CLine 就相对容易,直接点击右上角的debug 标志即可。

如果一切运行正常,在build 文件夹下面就会出现需要的 CmakeDemo 这个名字的二进制文件,因为我们需要的是静态库,那么到现在为止这个静态库可以被拿来使用了。

如何检验是否为自己需要的二进制静态库

我们想要提供出去稳定可靠的二进制库,那么自测是必须不可少的一环。讲一下我这边比较常用的方式,我会去创建一个适合的console 工程,或者如果使用xcode , 我会创建一个可视化的对应平台APP,把库文件暴露的接口交给新建demo 工程,按照正常使用的流程,载入编译出来的库文件。

那么开搞。

因为这里打算先提供一下简单的demo , 只演示一下如何使用CMake ,创建静态库和使用静态库,这里简单替换原来的打印名言Hello,world! 更改为 **”Hello, from static lib !” **

在进入build 文件夹执行过最后的 make 命令后,能够拿到库文件 libCmakeDemo.a

我们放入到consoleDemo 工程中 然后文件结构是

1
2
3
4
5
├── CMakeLists.txt
├── cmake-build-debug
├── libCmakeDemo.a
├── library.h
└── main.cpp

更改CMakeLists.txt 内容如下

1
2
3
4
5
6
7
8
9
10
11
12
#设置cmake version
cmake_minimum_required(VERSION 3.17)
#设置变量名字
set(APP_NAME ConsoleDemo)
# 国际惯例 project name
project(${APP_NAME})
#C11
set(CMAKE_CXX_STANDARD 11)
# 这里需要链接静态库
link_libraries(${CMAKE_CURRENT_SOURCE_DIR}/libCmakeDemo.a)
#正常编译需要的 添加source 文件
add_executable(${APP_NAME} main.cpp library.h)

我这边比较懒,就直接搬运了静态库中使用的头文件,因为属于函数定义,并且没有特殊要求,这里就直接可以拿来使用。

看下main函数中的修改

1
2
3
4
5
6
7
8
9
#include <iostream>
#include "library.h"

int main() {
hello();
std::cout << "Hello, World!" << std::endl;
return 0;
}

这里就很简单了,直接引入头文件,然后在合适的地方进行执行。

结果如下:

1
2
Hello, from static lib !
Hello, World!

如预期

先写到这里,下次可以讲一下全平台的一些东西,比如一些难点,如果从C++ 通过nodejs 异步回调给JavaScript(可不是正常设置进去一个回调然后等执行后就从C++那边执行到JavaScript ,这样就没有技术含量)使用libuv 可以多次从C++ 回调给JavaScript,下次讲讲如何实现。

惯例,使用的demo 地址 Demo 地址

如何使用vscode 调试 nodejs + C++

先讲遇到的问题

Electron 部分发新版本,windows包,在一些设备上出现了崩溃,没有堆栈,没有可用信息,怀疑的点有一大堆。

以上是问题,解决思路是从git提交记录进行比对找出一些最怀疑的点进行日志跟踪,尝试找出崩溃发生前最后日志出现位置,推测上下文。

如何定位问题先不细讲,最后大致定位在native log日志部分,比如表现,在一些机器上关掉调用就可以正常,打开调用就会崩溃。

确定了崩溃发生的位置,因为在不同的机器上才会有不同的表现,所以崩溃发生的原因还是不太详细,而且初始化调用的顺序是js端先调用node文件c++暴露的接口,传递需要初始化的完整路径,node 文件将加工后的路径交给log日志系统进行业务的初始化。因为出现问题,所以整个环节都是可以怀疑的地方。

如何缩小怀疑范围

比如从语言层面来讲,C++ 内部的相互调用机制这个基本可以排除怀疑,所以基本大概率怀疑的点应该是语言之间的交互部分,nodejs 提供的C++ Addon 部分就是怀疑对象的重点。

从结构来分,设计方案来讲,C++核心业务方是独立的模块儿,内部的逻辑和调用规则非常清晰,大概率问题不大。从语言交互部分来讲,如果按照规则使用问题也应该很少,对于整块儿业务来讲,这里出问题了,需要去排查,逐个排除。

发现规律应该能够减少更多的怀疑部分,比如这次遇到问题的时候发现在开发的机器上面没有任何问题,在用户的机器上面就能必现。不同的点就是用户的home 文件夹是使用中文的,开发和测试侧使用的是英文,开发侧有足够的依赖库可以正常使用,用户侧不确定是否全部包含。

目标锁定为中文是因为日志系统测试阶段没有考虑过中文路径,在开发时候会发现日志系统在windows 有额外处理部分,比如正反斜杠。

验证猜测

C++ 核心功能部分被编译为了静态库,先编译debug 静态库,然后新建console 工程 载入和使用,打断点就能确认是否好使。确认传入的中文路径在日志初始化状态没有任何问题。这块儿基本排除了因为C++ 的可能性。所以下一步测试从JavaScript 层传入中文路径,看Node 文件在接收参数之后传递给C++ 的值到底会是什么。带来一个新的问题,如何调试,如何断点。单一的打log并不能很直接的跟踪定位到问题所在,个人经验,跟踪log来解决手头问题令人抓狂又很低效。

调试需要用的工具

我们拿到关键字去搜索发现,可以debug C++ ,vscode 有插件儿 CodeLLDB ,因为需要调试C++ ,所以 C++ 调试插件 不可少,需要调试node ,nodejs 系统安装也需要必备。

  • Debugging on Linux (x64 or ARM), macOS and Windows*,

  • Conditional breakpoints, function breakpoints, data breakpoints, logpoints,

  • Launch debuggee in integrated or external terminal,

上面是CodeLLDB 的介绍,可以在不同的平台方面提供断点等debug 功能。

**C/C++ **插件儿不多解释,需要断点C或者C++ 不是。

至于 Nodejs ,今天需要讲的重点,断点需要调试的就是 Nodejs addon,从JavaScript 启动可以断点js ,也可以断点到执行到的C++ 部分。

怎么做

先讲怎么做,然后再说为什么。

讲一下为什么

需求是需要跨平台 debug nodejs 和c++ 部分,编辑器是vscode。从vscode 的插件儿开始入手。C++ 和 Nodejs 属于必备,关心的重点就是CodeLLDB。

如何使用CodeLLDB,比如他们快速开始。

"CodeLLDB_quick_start"

name Launch configuration name. 明显是指的标识,type 应该是告诉vscode 需要执行哪一类调试。

request: The request property of the configuration chooses how it will be done。 可以暂时理解为目的。

program:Path to the debuggee executable. 需要launch执行的路径。

args: Command line parameters. If this is a string, it will be split using shell-like syntax. 执行时候需要传递的参数

cwd: Current Working directory. 当前工作路径

1
2
3
4
5
6
7
8
{
"type": "lldb",
"request": "launch",
"name": "lldb:node",
"program": "node",
"args": ["--inspect-brk", "--expose-gc","main.js"], // --inspect-brk 参看nodejs , node --expose-gc main.js 可以手动代码执行gc()
"cwd": "${workspaceFolder}",
}

上面的这边在使用的lldb launch 配置。

"nodeinspect"

在使用launch的时候我们使用了inspect 的参数,于是当我们真正调试的时候,需要监听的端口号为9229。

1
2
3
4
5
6
7
8
9
{
"type": "node",
"request": "attach",
"name": "node:attach",
"port": 9229,
"skipFiles": [
"<node_internals>/**"
]
}

有了上面的基本介绍,这里的一些参数似乎显得就是很自然的一些内容。skipFiles vscode nodejs debug介绍

因为需要经历两个步骤,先launch 需要debug 的内容,然后attach 住正在执行的内容,才能够达到debug 的目的。

compounds: vscode 提供了这个字段,很明显是为了大家执行方便,让逻辑按照执行顺序去执行。

preLaunchTask字段,从字面意思就能了解是在做一些列操作之前可以先执行某种动作。

compounds

演示的视频是在Mac 下面做的,在windows下面配置部分会略微不同,但是流程是一样的,需要先launch ,然后在attach node 文件,进而在需要的地方进行断点。

附加: :视频中用于测试的Demo 工程 Git 地址 git@github.com:waitingchange/vscode_debug_nodejs_cpp.git

#如何玩转 Cocos 文件系统

1.资源是如何被找到的

   没做开发以前一直在想一个问题,文件如何被计算机找到并且执行? 当知道计算机在执行 0101 的二进制文件时候就又在想,我们的图片、声音等文件应该是变成了机器代码之后才能被读出来和执行。于是抱着这种想法开始了codeing。偶然有一次发现,Android 的 apk 包竟然可以用zip 解压工具解开,里面竟然找到了一些未经过压缩的图片和其他资源。在好奇心的驱使下就想要搞明白计算机是如何做到的。在玩儿 Cocos 的过程中,接触到了FileUtils这个类文件。顺着源码部分我们去看,发现在经过引擎一系列优化以后,查找文件代码最终调用了系统方法,根据文件的名字,在APP的指定路径(包里面或者可读写路径下)返回资源文件的全路径。

   似乎经过上面一说,整片文章就可以结束了。以Cocos lua 为例子 我们尝试列一下流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//先说一下 luafile  部分
// cocos lua 部分入口是在c++ 初始化 LuaEngine 以后执行lua 代码
if (engine->executeScriptFile("src/main.lua"))
// LuaEngine 之后做了如下操作
LuaStack 去执行了 executeScriptFile(filename);
// 判断结尾,确认是 lua 还是 luac
std::string fullPath = utils->fullPathForFilename(buf);
Data data = utils->getDataFromFile(fullPath);

// data 存在 执行
if (luaLoadBuffer(_state, (const char*)data.getBytes(), (int)data.getSize(), fullPath.c_str()) == 0)
{
rn = executeFunction(0);
}

   脚本文件在执行之前,需要通过引擎代码 FileUtils 进行定位文件具体位置,然后获取文件buffer 内容和长度以后进行执行。

   我们于是可能会好奇图片文件是如何被载入和执行的,上面的流程是否满足图片资源的需求 ?

1
2
3
4
5
6
7
8
9
10
11
12
// Talk is cheap , show me the code
Sprite::create(fileName)
// 跟踪fileName
if (sprite && sprite->initWithFile(filename))
//Cocos 使用了 Texure2d
Texture2D *texture = Director::getInstance()->getTextureCache()->addImage(filename);
//继续关心fileName 是如何最后变成 Texture2D 对象的
std::string fullpath = FileUtils::getInstance()->fullPathForFilename(path);
bool bRet = image->initWithImageFile(fullpath);
// 于是继续向下查找
_filePath = FileUtils::getInstance()->fullPathForFilename(path);
Data data = FileUtils::getInstance()->getDataFromFile(_filePath);

   似乎图片资源从file到 机器可识别的Data 也需要关键的两步,findPath ,getData

   我们可以继续去验证其他后缀结尾的文件,大多数都在执行同样的流程。为甚叫做大多数呢 ? 当你实验到音频文件的时候,例如 mp3 ,最后执行的貌似并不是当前的流程。读代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 感兴趣的点在哪里
AudioEngine::play2d
//屡一下源码部分 容错处理
if ( !FileUtils::getInstance()->isFileExist(filePath)){
break;
}
ret = _audioEngineImpl->play2d(filePath, loop, volume);
//在ios一侧 使用了 SimpleAudioEngine
-(void) playBackgroundMusic:(NSString*) filePath
{
[am playBackgroundMusic:filePath loop:TRUE];
}
// 看到OC 代码的时候 就会发现,对于 SimpleAudioEngine 库来讲,需要的仅仅是文件的绝对地址 ,这个音频库会找到 文件然后进行播放

   我们希望引擎中代码部分和整体的设计流程保持一致,大多数文件是保持了先经过 FileUtils 获取到文件在设备存放的位置,然后读取file Buffer 之后再次加工处理。

   设计结构的时候,我们经常会对内容进行归类,找出共同点,然后进行粗略处理,最后不同之处做区分。现实情况中我们总会发现Cocos原生打包内容资源部分容易被盗,脚本代码也很容易被还原,安全方面几乎无法保证,虽然逻辑部分能正常work 。

   代码的主要逻辑在于如何获取文本 BufferLength 面对上面的常见问题, 所以具体原始文件是什么形式我们不是特别关心,只是要给到 fileName 我们正确的返回 BufferLength 就可以了。 加入文件名字是被算法加密了呢?假如文本内容是被高效率解析加密函数加密了呢?很多事情貌似可以直接上手做。设想和做是两回事儿。

   万一文件名字和内容都被一个加密算法加密了呢 ?比如ZIP

   为什么是zip ? 在读FileUtils 类的时候有一个函数很吸引人 FileUtils::getFileDataFromZip

   选择ZIP会有一些优势,最简单常见的是可以给资源进行压缩和加密,如果是需要进行热更操作的资源呢?连续性大文件下载的优势就有了,而且具有压缩的特点,资源或者代码的热更时间会大大减少。有没有了解过zip文件的结构?考虑到每一个文件,你会关心哪些数据?简单理解ZIP文件为一大片连续的数据段?

   我们在读Cocos 提供的unzip文件的时候会发现几个比较有意思的函数

1
2
3
4
5
6
7
8
9
unzOpen
unzGoToFirstFile64
unzGetFilePos
unzGoToNextFile64


unzGoToFilePos
unzOpenCurrentFilePassword
unzReadCurrentFile

如果理解ZIP 文件为长串儿连续压缩文件的话也并不是不可以,只不过每一个文件头部都有包含一些信息:1.文件名字,无压缩文件大小,起始文件地址等。依靠这些文件已经可以把压缩文件解压缩读出。

Demo 暂时不列出,思路可以提供。

一步一步获取Cocos2dx xxtea 密码

1.准备工具

  1. Android 手机一部,最好是 32位处理器,必须能root

  2. IDA Pro

  3. cocos apk 确认使用xxtea (cocos new HelloLua -l lua 然后生成apk)

2.电脑环境 win or Mac

​ 电脑需要能够使用 adb 命令 ,建议安装 apktool (防止apk 没有配置 android:debuggable=”true”,如果apk包不能直接调试请自行查找如何重新打包apk ) ,Android Studio 需要安装 (主要为了分析 manifest 文件 Application 中是否配置android:debuggable=”true” 另外需要使用 sdk 文件夹 tool 下面 monitor ,win 下面可以使用 ADT Bundle 中 ddms ) 因为个人使用习惯问题,这里讲述如何在mac 系统下面操作。

3.具体操作步骤

  1. Android 手机具有root 权限 而且已经安装被调试的apk包,在pc端 打开终端输入

    1
    2
    3
    4
    adb shell 
    su
    cd /data/app #查看包名文件夹是 为debug 启动 应用做准备
    ls -l

    在真正启动调试以前需要把 ida pro 提供的 android_server 文件放在 Android手机的 /data/local/tmp 文件夹下,操作方式可以使用 adb push 或者拷贝文件到手机SD 卡中,然后通过命令形式挪动到 /data/local/tmp重要的是android_server 文件需要给与可执行权限, 所以Android 手机需要有Root 权限

    1
    2
    3
    4
    5
    adb shell 
    su
    cd /data/local/tmp
    chmod 777 android_server
    ./android_server

    "执行终端截图"
    "执行android_server"

    从截图可以看到已经启动了android_server 端口是 23946 , 需要使用 adb命令将端口映射至电脑 23946 为啥是这个端口? Ida Pro 在Attach Android时候默认监听端口是这个
    "adb 转发"

    准备工作应该差不多了,剩下的就是如何调戏Apk

    1
    2
    adb shell
    am start -D -n org.cocos2dx.HelloLua/org.cocos2dx.lua.AppActivity #debug 形式启动apk

    "命令启动apk"

    am start 启动Android Activity -D: 允许调试功能 , -n : 指定组件名,格式为{包名}/.{主Activity名}

    "App启动后截图"

  2. 启动 DDMS (win) or Monitor (mac)

    也许会在想,为啥是DDMS ? 自己粗略的理解,为了查看App debug 信息(不一定正确,如果有确定的答案麻烦通知我)。 在Devices 里面能够看到等待调试的app信息,包名,端口等 但是发现debug 的标识还是红色,意思是暂时还不能调试,所以监视器已经被启动,剩下操作如何使用IDA 连接手机然后调试
    "DDMS能看到的内容"

  3. IDA 部分操作
    准备工作和启动APP 已经结束了,剩下的是需要借助 IDA 这货来获取我们想要的内容。好戏才刚刚开始~
    我在使用 Mac 版本 IDA Pro 7.0 ,具体下载地址暂时不提供,毕竟业余水平的我也买不起。

    IDA Pro 一直存在 两个可执行文件, 32 位和 64 位之分。区别在于 32 位可以使用 F5 把二进制中函数部分难以理解的汇编转化为稍微容易可读的 C 语言。 推荐暂时使用 32 位,所以 也推荐使用 32 位处理器的 Android 手机(2019 年的今天已经很难找到,我的测试机器是A0001, 骁龙 801 处理器 64位 ,尴尬的笑脸),细心的同学们会发现在 IDA 提供的 android_server 文件在 dbgsrv 文件夹下面包含两个, 另外一个文件是 android_server64 (使用这个文件的时候请打开 IDA Pro 64)。啰嗦到此为止,我们已经打开了 IDA Pro ,需要操作的是 Attach Android 设备。

    "IDA Attach"

    点击 Remote ARMLinux / Android Debugger

    "Set Debugger"

    然后点击 Debug options

    "Debug options"

    请根据我的设置进行配置,如果你想要别的功能,那么请自行设置,然后点击确定。

    回到Debugg application setup 界面, Hostname 请写localhost 或者 127.0.0.1 端口号默认 23946 然后点击OK

    在新弹出的页面请点击seach 然后输入你需要调戏的包名(可以输入部分关键字进行查找)
    "Debug options"

    然后就是点击OK
    "Debug"

    到现在这个地方才是真正开始准备获取cocos xxtea 密码的位置。 正如前面讲到的,Monitor 中显示 APP Debug 的标识是红色的,meaning 不能调试。 解决这个问题需要一行命令,终端输入如下

    1
    jdb -connect com.sun.jdi.SocketAttach:hostname=127.0.0.1,port=8700 #如果 8700 不好使 那就试一下 8600  为什么请问Google

    "Debug"

    当执行完命令时候 ,Monitor 中 debug的图标变成了绿色,所以,下一步就是开始反编译

    在准备下一步的时候我们需要考虑,目的到底是什么? 需要看哪里 ? 已知:IDA Pro 可以在二进制部分函数中进行打断点单步调试。在 Cocos 中,C++ 中的代码会最终编译到 libcocos2dlua.so, 所以需要先定位到 so包,然后再次定位到相应函数,最基本的执行逻辑。IDA Pro 可以进行断点,所以需要再函数执行以前进行打上断点,等执行到断点时候查看内容。问题来了,如何做 ? 在什么地方打断点?如何判断so包是否被载入?如何判定打断点的函数就是你需要的函数 ? 如何查看自己想要的值 ?一些列的问号等着解决

  4. 知识补充 如何查找需要debug 的函数

    目的:拿到 xxtea 解密密码。 目的很明确,所以先找到Cocos 源码部分,找到xxtea.h

    1
    2
    unsigned char *xxtea_encrypt(unsigned char *data, xxtea_long data_len, unsigned char *key, xxtea_long key_len, xxtea_long *ret_length);
    unsigned char *xxtea_decrypt(unsigned char *data, xxtea_long data_len, unsigned char *key, xxtea_long key_len, xxtea_long *ret_length);

    xxtea_decrypt 多么刺眼,如果,我指的是如果,Cocos 的包开发者没有这块的安全意识,基本上这块的代码应该不会改动。所以,IDA 静态反编译libcocos2dlua.so ,在能够列出的函数列表中我们查找 xxtea 字段,万一能够查到我们想要的内容呢 ?

    我另外开启了一个 IDA 用来静态反编译 ** libcocos2dlua.so** , 在function 列表中查找 xxtea 如下截图
    "Debug"

    有的时候结果就是这么的让人欣喜。能找到这个函数,那么剩下的问题是如何在合适的时间打上断点,然后让APP断在我们想要的位置 ?在这个之前,得先找到什么时候SO 包被载入。所以往下面看。

  5. 继续跟踪调试

    回到我们使用IDA Pro 连接好的准备调试界面,我们在IDA 的debug 选项中选择了加载so包的时候断住,但是在我的测试中发现貌似我这个在载入libcocos2dlua.so
    并没有断住。同时也发现暂时并没有载入 libcocos2dlua.so 。所以开始想办法,如何去断点调试。IDA Pro 提供了 F8 单步调试F9 继续执行。在右侧的 Modules 中 ctrl + f 查找 cocos 发现没有内容。使用比较粗糙的方法,F8 一下一下的嗯,遇到弹框就点击继续一直到libcocos2dlua.so载入成功 在SO 载入成功以后,Modules 里面也出现了我们想要的 SO 包。赞!

    "Debug"

    双击 Modules 中 libcocos2dx.so ,然后查找 xxtea
    "Debug"

    在这里打完断点以后可以使用 F9 让App 执行到断点位置
    我们在断点位置摁一下F5 会怎样 ?(如果使用了 64 位的IDA ,那么貌似暂时不支持)
    "Debug"

    我们看到汇编语言版本的代码被ida 转化为了 略微熟悉的 C 语言, 根据我们xxtea_decrypt 源码我们发现,第三个只才是我们需要的 key 。 胜利在望,我们移动鼠标放在 a3 上面,发现 r2 寄存器才是我们要查找的内容
    "Debug"

    双击一下 a3 会如何 ?
    "Debug"

    或者在下面的 Hex View-1 中点击右键 Synchorize with 中选择 R2
    "Debug"

4.密码部分已经给你了,其余的就自己看着办吧

​ ps. 转载前请先联系我 waiting0313@gmail.com
提供测试 apk 下载 点击下载

述求如题,怎样获取托管网站中分发的iOS安装包 ?

  百度搜索,关键字 fir.im iOS安装包 ,第一篇,使用抓包。鉴于我不太会使用,所以暂时先放弃,其余的,跟关键字似乎无关。只有抓包才好使 ?保持怀疑。。。

  先把如何获取到放在一边,尝试了解一下需要下载的是什么。企业签名包如何制作,继续百度关键字,出现的结果都是多少多少钱,吐槽弃用。作为一个技术,我自己主要的搜索是Google,从这里我们可以找到相对满意的答案。比如我查到的结果 企业签名如何制作。企业签名包在最后会生成一个plist文件,提供链接给ios设备Safari ,然后就可以自动安装。

  回归主题,汇总知道,需要下载plist , 需要使用Safari。所以我们需要去fir.im 中去猜想他们是如何操作的的。

  1.首先我们需要一个fir.im 中对于APP 的一个下载地址,比如:https://fir.im/2cra (网上资源,如果侵权请联系删除)

  2.iPad 或者 iPhone中使用 Safari 打开链接。

  3.手机中开启 Safari中高级设置 –> Web Inspector

  4.手机连接电脑,打开Safari ,Developer –> ipad —> fir.im/2cra 如图

"inspect 截图" 查看网页源码,根据文件名字,最感兴趣的应该是download.js 。 所以我们查看文本内容。根据我们获取的信息知道plist下载链接前面需要有 itms-services://?action=download-manifest&url= 所以,我们查找之后的截图如下

"plist链接如图" 匹配到的函数是 plistUrl 和 iosSchema

Safari 提供断点跟踪,所以,开始搞事情!!!

"plist真实链接" 貌似拿到了下载链接,如何验证呢 ?

  chrome 里面装上一个插件,使用iPad 访问链接,比如 User-Agent Switcher for Chrome 在头部加上http:// 扔到浏览器访问,直接出来下载的plist 文件名字叫做 install 我们使用文本工具查看文件内容"install 文件"

  https://ali-fir-pro-binary.fir.im/fe5b21535e0c908bb95adc7560c8ab8db9a914fa?auth_key=1548583391-0-0-73f83a6c4d6151baba81747148ff0f8b 链接地址就是ipa下载地址,丢到浏览器,完事儿。

Cocos 中脚本文件是如何被载入和执行

  Cocos 是可以执行 lua 和 js 的,lua 和 js 都可以认为是解释性语言,不涉及编译和链接步骤,那么文件是如何在Cocos中被载入到内存中并且被执行呢 ?

  前提,我们知道不管是IOS 系统还是 Android 系统,Cocos 的执行流程都是需要把MainLoop 执行起来。如果这个流程不太清楚,请参看我上一个帖子 Cocos 代码执行流程

  以Cocos lua 为例,前面的代码部分已经解释了,在执行MainLoop 之前,AppDelegate::applicationDidFinishLaunching 函数中会先执行lua 虚拟机的初始化 和 设定 脚本执行的入口文件,我们继续跟踪代码执行,去发现Cocos 在文件操作方面是具有如何的特点。

1
engine->executeScriptFile("src/main.lua")

  对于main.lua 我们很熟悉,cocos-lua 中lua脚本执行入口文件。我们知道硬盘可以容纳很多的文件内容,但是这些文件内容在没有被计算机读取的时候就仅仅是存放而已,真正有效是在载入到内存以后被cpu处理的时候,话外题,先pass。我们继续回到main.lua ,同样,在入口的时候一定会先将文件载入到内存,那么文件的IO操作一定少不了。因为使用lua语言编写的,所以lua解释器需要介入和解释文本内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//猜想执行流程
if(isFileExistence){
//执行文本IO 根据文本类型读取内容
FILE *fp = fopen(fullPath.c_str(), mode);
fseek(fp,0,SEEK_END);
//获取长度
*size = ftell(fp);
fseek(fp,0,SEEK_SET);
//获取文本内容 放入内存
buffer = (unsigned char*)malloc(*size);
*size = fread(buffer,sizeof(unsigned char), *size,fp);
fclose(fp);
...
//判断如果文本是经过 xxtea 加密
//拿到加密的buffer 进行解密 返回原始内容
//拿到原始内容以后进行解释执行
}

  如果这里没其他需求,最简单和直接的思路应该是这样的,所以我们转向看下Cocos 的代码部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static const std::string BYTECODE_FILE_EXT    = ".luac";
static const std::string NOT_BYTECODE_FILE_EXT = ".lua";
int LuaStack::executeScriptFile(const char* filename)
{
std::string buf(filename);
// remove .lua or .luac
...
FileUtils *utils = FileUtils::getInstance();
// 1. check .lua suffix 2. check .luac suffix
std::string tmpfilename = buf + NOT_BYTECODE_FILE_EXT;
if (utils->isFileExist(tmpfilename)){
buf = tmpfilename; // .lua file
}else{
tmpfilename = buf + BYTECODE_FILE_EXT;
if (utils->isFileExist(tmpfilename)){
buf = tmpfilename; // .luac file
}
}
//获取全路径
std::string fullPath = utils->fullPathForFilename(buf);
Data data = utils->getDataFromFile(fullPath); //获取buffer
int rn = 0;
if (!data.isNull()){
//载入lua buffer
if (luaLoadBuffer(_state, (const char*)data.getBytes(), (int)data.getSize(), fullPath.c_str()) == 0){
rn = executeFunction(0); //执行栈顶 func
}
}
return rn;
}

  跟Cocos 这边进行对比发现基本逻辑实现好像差不多(所以我们也可以按照自己意愿封装自己的引擎了)。文本最终执行到了lua虚拟机然后进行函数执行。解释到这里似乎基本上已经完事儿了,是确定这样的吗?会有好奇心想要知道文本如何高效率精准被找到的吗?

1
2
3
//获取全路径   如何做到的 ?
std::string fullPath = utils->fullPathForFilename(buf);
Data data = utils->getDataFromFile(fullPath); //获取buffer 有什么其他要注意的吗 ?

  在我们的FileUtils 中有对外暴露的 setSearchPaths 函数,直面翻译函数名字叫做设置文件搜索路径。在我们不知道内部如何实现的时候我们可以尝试使用自己的思路进行猜测别人的逻辑如何实现,如果最后发现很接近,这样是不是很开心?如果发现不太一样,我们是不是能学到一些别人优秀的设计思想 ?继续回归代码层面,我们先做设想。设置进来的是文件路径数组,那么数组有两个目的,第一临时存储,第二方便遍历。所以从方便遍历的角度来讲进行文件查找,我们推测。

1
2
3
4
5
6
7
if(isFileNameExistence){
for(int i =0;i < arry.length;i++){
//1.取出每一个字符串中的内容 + 传递进入的文件名字
//2.判断 isFileExist(tmpfilename) 存在的话就返回拼接后的字符串,
}
//在最后如果没找到就返回没找到标识
}

  我们看下源码实现部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

std::string FileUtils::fullPathForFilename(const std::string &filename) const
{
if (filename.empty()){
return ""; //空字符串监测
}
if (isAbsolutePath(filename)){
return filename; //绝对路径直接返回 优化的点
}
// Already Cached ? 加入了缓存机制 _fullPathCache
auto cacheIter = _fullPathCache.find(filename);
if(cacheIter != _fullPathCache.end()){
return cacheIter->second;
}
// Get the new file name. 缓存机制中使用了字典,所以字典两边都需要进行监测是否已经存在
const std::string newFilename( getNewFilename(filename) );
std::string fullpath;
// 遍历搜索路径
for (const auto& searchIt : _searchPathArray) {
//这里有一个分辨率搜索顺序的Vector 如果有多套分辨率 下面的资源和脚本路径结构一样可以进行设置,默认为空字符串
for (const auto& resolutionIt : _searchResolutionsOrderArray){
//拼接内容 获取字符串内容 同时判断文件是否存在 存在返回全路径
fullpath = this->getPathForFilename(newFilename, resolutionIt, searchIt);
if (!fullpath.empty()){
// Using the filename passed in as key. 全路径不为空插入缓存 map 方便下次查找使用
_fullPathCache.insert(std::make_pair(filename, fullpath));
return fullpath;
}
}
}
if(isPopupNotify()){
CCLOG("cocos2d: fullPathForFilename: No file found at %s. Possible missing file.", filename.c_str());
}
// The file wasn't found, return empty string. 找不到返回空字符串
return "";
}

  所以看下执行的逻辑和顺序,思考一下别人巧妙的思想。鉴于cocos脚本和资源部分大多数都要走fileUtils ,是不是我们可以做一些更巧妙的东西加入到cocos 的源码中 ?

(╯^╰〉错删仓库,以前的blog不见了!!!

tinypng for python

首先需要先安装 tinify

1
$ pip install --upgrade tinify

获取tinypng API 密钥

1
2
import tinify
tinify.key = "YOUR_API_KEY"

申请地址 : 地址
注意 每一个API_KEY每一个月只能压缩500张图片,如果有需要可以多申请几个备用。

使用方法

1.可以选择一个本地文件作为原始图片,压缩后将其写入到另一个文件。

1
2
source = tinify.from_file("unpressed_Source_Imag.jpg")
source.to_file("outPut_Imag.jpg")

2.可以上传(二进制字符串)的图像来获得压缩图象数据。

1
2
3
with open("unpressed_Source_Imag.jpg", 'rb') as source:
source_data = source.read()
result_data = tinify.from_buffer(source_data).to_buffer()

3.可以提供一个图片的URL来获取压缩后图片

1
2
source = tinify.from_url("https://cdn.tinypng.com/images/panda-happy.png")
source.to_file("outPut_Panda_Imag.jpg")

常用的就这样三种类型,当然如果需要压缩同时更改图片尺寸的话可以翻看官网用法

提供一个可用脚本

脚本下载地址

设想一下,一个游戏有N多图片资源,分为M个文件夹,手动使用TexurePacker打图实在是一个体力活,同时还有忙中出乱的风险。使用脚本批量处理是不二的选择(当前环境为mac)

1.安装 TexturePacker 然后安装 TexturePacker Command Line Tool

"install图片"
如上图所示,安装 TexturePacker Command Line Tool

2.打开Teminal 输入 TexturePacker

"Teminal图片"
​ 1.检查是否安装成功
​ 2.细看 Options:内容的 Output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
--sheet <filename>   要输出的文件的名字       Name of the sheet to write, see texture-format for formats available
--texture-format <id> 要输出的图片格式 png、pvr2、pvr2gz、jpg 等 Sets the format for the textures.
--data <filename> plist文件的名字 Name of the data file to write
--format <format> 类型 cocos2d 、unity 等 Format to write, default is cocos2d
--force-publish 忽略hash_key 每次都强制从新输出 Ignore smart update hash and force re-publishing of the files
--texturepath <path> 输出路径 Adds the path to the texture file name stored in the data file.
--border-padding <int> 图片之间间隔 Sets a padding around each the border, value is in pixels, default is 2
--shape-padding <int> 图片形状间隔 Sets a padding around each shape, value is in pixels, default is 2
--trim-mode <value> 删除透明像素恢复原始大小,加快渲染 Remove transparent parts of a sprite to shrink atlas size and speed up rendering
None - Keep transparent pixels
Trim - Remove transparent pixels, use original size.
Crop - Remove transparent pixels, use trimmed size, flush position.
CropKeepPos - Remove transparent pixels, use trimmed size, keep position.

--basic-sort-by 图片排序 Sort order for the sprite list
Best - Best
Name - Name
Width - Width
Height - Height
Area - Area
Circumference - Circumference
--basic-order 方向排序 Sorting direction
Ascending - Ascending
Descending - Descending
--scale <float> 缩放 Scales all images before creating the sheet. E.g. use 0.5 for half size
--max-size <int> 最大的尺寸 Sets the maximum width and height for the texture in auto size mode, default is 2048
--size-constraints <value> 结纹理进行大小格式化,AnySize 任何大小 POT 使用2次幂 Restrict sizes
POT - Power of 2 (2,4,8,16,32,...)
AnySize - Minimum size
NPOT - Any size but power of 2

3.知道限定条件,那么命令也大概能知道了

1
2
3
4
5
6
7
8
9
10
11
12
13
Examples:
TexturePacker assets/*.png
creates out.plist and out.png from all png files in assets
trimming all files and creating a texture with max. 2048x2048px

TexturePacker --data main-hd.plist --format cocos2d --sheet main-hd.png assets/*.png
creates main-hd.plist and main-hd.png from all png files in assets
trimming all files and creating a texture with max. 2048x2048px

TexturePacker --scale 0.5 --max-size 1024 --data main-sd.plist --format cocos2d --sheet main-sd.png assets/*.png
creates main-sd.plist and main-sd.png from all png files in assets
scaling all images to 50%, trimming all files and creating
a texture with max. 1024x1024px

4.模板有了之后,剩下的就是实现了

我自己随便写了个脚本,地址 : 地址

1.Cocos对于IOS来讲

我们知道C语言/C++ 语言的程序入口是 main , IOS 工程一样入口文件在于 ios/main.m

1
int retVal = UIApplicationMain(argc, argv, nil, @"AppController");

这一行代码解释:IPhone初始化应用程序对象,并且传递给应用需要使用的代理类并初始化

1
2
3
4
5
6
int UIApplicationMain (
int argc,
char *argv[],
NSString *principalClassName,
NSString *delegateClassName
);

解释这个函数:第一个,函数传入参数的个数,第二个,参数列表,第三个,UIApplication或其子类的名字,如果设置为nil,默认为UIApplication 第四个,应用程序使用的代理类名,传入以后会进行实例化然后给Application 设置代理。如果(principalClassName)为nil,程序会从Info.plist中获取,如果Info.plist中没有响应的Key,那么默认是UIApplication

所以main调用以后代码会在 AppController 中执行

因为AppController 是 UIApplicationMain 的代理

1
2
3
4
5
6
7
8
9
10
11
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
//这个函数体会显现的比价重要
//cocos Application的单利
cocos2d::Application *app = cocos2d::Application::getInstance();
//中间是一系列的初始化
...
//执行了 cocos Application 的run方法
app->run();
return YES;
}

当我们发现 cocos Application 生成单利的时候会发现仅仅是返回了 Application 的单利,没有涉及到构造函数,略微感觉奇怪。

1
2
3
// cocos2d application instance  全局静态类对象?
static AppDelegate s_sharedApplication;

是全局静态类对象,初始化在main函数执行以前,一旦被创建,在程序执行结束以前都不会销毁

AppDelegate会不会有一些熟悉?

初始化就会执行下面代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Application* Application::sm_pSharedApplication = 0;
//初始化就执行构造函数,构造函数给单利对象赋值
Application::Application()
{
CC_ASSERT(! sm_pSharedApplication);
sm_pSharedApplication = this;
}
...
//提供给静态函数调用使用
Application* Application::getInstance()
{
CC_ASSERT(sm_pSharedApplication);
return sm_pSharedApplication;
}

到现在这个位置,cocos Application 生成了对象,并且要执行 app->run();

神秘的地方会在run()

1
2
3
4
5
6
7
8
9
10
int Application::run()
{
//先执行c++部分的 applicationDidFinishLaunching 正是AppDelegate的方法 正是AppDelegate继承于cocos2d::Application
if (applicationDidFinishLaunching())
{
//启动 mainLoop 游戏运转的入口
[[CCDirectorCaller sharedDirectorCaller] startMainLoop];
}
return 0;
}

对于cocos lua

1
2
3
4
5
6
7
8
9
10
bool AppDelegate::applicationDidFinishLaunching()
{
//初始化 lua虚拟机然后制定函数的入口部分加入正确的资源搜索路径
//然后执行 (如果是.lua)
if (engine->executeString("require('src/main')")){
return false;
}
return true;
}

lua 虚拟机初始化完毕以后就开始执行了lua 的入口部分 然后执行了启动 mainloop

1
2
//如果认为直接是 Dierctor 执行的mainloop 那么就有一些失望了  CCDirectorCaller 这个才是
[[CCDirectorCaller sharedDirectorCaller] startMainLoop];
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-(void) startMainLoop
{
// Director::setAnimationInterval() is called, we should invalidate it first
[self stopMainLoop];
//设置定时器 设置定时回调 doCaller
displayLink = [NSClassFromString(@"CADisplayLink") displayLinkWithTarget:self selector:@selector(doCaller:)];
[displayLink setFrameInterval: self.interval];
[displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
}
...
-(void) doCaller: (id) sender
{
cocos2d::Director* director = cocos2d::Director::getInstance();
[EAGLContext setCurrentContext: [(CCEAGLView*)director->getOpenGLView()->getEAGLView() context]];
// 正常安装60帧每秒进行调用 最终会执行到 熟悉的 Director 的 mainLoop
director->mainLoop();
}


最关键的部分来了 mainLoop

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//为啥不是Director 
//翻看头文件 class DisplayLinkDirector : public Director 调用父类纯虚函数 执行子类方法 (多态)
void DisplayLinkDirector::mainLoop()
{
//是否销毁Director 在下一帧
if (_purgeDirectorInNextLoop)
{
_purgeDirectorInNextLoop = false;
purgeDirector();
} //是否重启Director 在下一帧
else if (_restartDirectorInNextLoop)
{
_restartDirectorInNextLoop = false;
restartDirector();
}//是否可用
else if (! _invalid)
{
//核心的部分在于 draw 既然是游戏,那么画布就需要跟黑板一样先擦干净,然后画图案显示 画的足够快(限定氛围内),动作在人的视觉范围内就是动画
drawScene();

// release the objects 想想这里 AutoreleasePool 关于内存管理的部分 如果没暂停,每一帧结束的时候倾倒池子中的对象
PoolManager::getInstance()->getCurrentPool()->clear();
}
}

"IOS 执行流程"

先写到执行mainloop,cocos中每一个对象如何被渲染下次再细讲

2.对于Cocos Android 的执行流程

如果创建一个空的Android 工程,工程的内容基本包含 AndroidManifest.xml ,Android 逻辑部分 java ,需要的静态资源 res等。

创建一个Cocos 工程同时,Cocos 已经创建了IOS 工程和Android工程, Cocos 的这个Android 工程可以理解为一个空的Android工程

想要知道游戏的启动流程,就需要先知道正常Android 中每一个APP的启动流程

Android 是基于Linux 的,Android 应用安装以后,应用图标可以理解为在一个Launcher 上面挂着,当点击图标的时候

1.系统会去调用 **startActivity(Intent) **

2.第一次执行 Android ActivityManagerService 会创建新的进程来实例化目标activity.

把关注点切回到代码部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher">
<!-- Tell Cocos2dxActivity the name of our .so -->
<meta-data android:name="android.app.lib_name"
android:value="cocos2dlua" />
<!--Android Activity name and path -->
<activity
android:name="org.cocos2dx.lua.AppActivity"
android:screenOrientation="landscape"
android:configChanges="orientation|keyboardHidden|screenSize"
android:label="@string/app_name"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

AndroidManifest.xml 文件暂时可以先理解为配置文件。 Android 系统拿到包名,拿到Activity 文件路径,开始执行Activity 的初始化

1
2
3
4
5
6
7
8
public class AppActivity extends Cocos2dxActivity{
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//执行了java Activity 的初始化 没有额外的新奇的代码了 少点什么 ?还是 ?
...
}
}

看了代码好像什么都没写,但是Cocos 给出的代码就是这样的,是有问题还是 ?

所以 public class AppActivity extends Cocos2dxActivity 这句话提醒了我们,AppActivity 继承与 Cocos2dxActivity

想要知道 Cocos2dxActivity 真正具有什么内容,需要看下类本身

1
public abstract class Cocos2dxActivity extends Activity implements Cocos2dxHelperListener 

代码部分显示 Cocos2dxActivity 继承于常见的 Activity 和 CocosHelperListener

我们暂时关心的是代码执行的逻辑顺序,于是现在急于知道Cocos2dxActivity的onCreate

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
protected void onCreate(final Bundle savedInstanceState) {
//国际惯例 super
super.onCreate(savedInstanceState);
onLoadNativeLibraries(); // 根据manifest 中cocos lib 的名字进行载入so库
...
//hadler 构造和初始化
this.mHandler = new Cocos2dxHandler(this);
Cocos2dxHelper.init(this);
//设置Layout ...
this.init(); //根据函数命名基本感觉这个比较重要
// window 啥啥啥
}

好奇init 函数会有什么惊喜

1
2
3
4
5
6
7
8
9
10
public void init() {
// FrameLayout 什么什么的
// Cocos2dxGLSurfaceView 创建 GLSurfaceView
this.mGLSurfaceView = this.onCreateView();
// 设置渲染 创建渲染 这里面会有什么特别的 ?
this.mGLSurfaceView.setCocos2dxRenderer(new Cocos2dxRenderer());
this.mGLSurfaceView.setCocos2dxEditText(edittext);
// Set framelayout as the content view
setContentView(mFrameLayout);
}

new Cocos2dxRenderer() 这个会让我感兴趣,创建了一个render ,这个render 里面会做什么 ?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class Cocos2dxRenderer implements GLSurfaceView.Renderer // 就是Gl 需要用到的render
...
@Override
public void onSurfaceCreated(final GL10 GL10, final EGLConfig EGLConfig) {
//根据宽高进行初始化内容 nativeInit 会比较感兴趣 这块最终是调用到c++ 部分 jni 调用
Cocos2dxRenderer.nativeInit(this.mScreenWidth, this.mScreenHeight);
...
}
@Override
public void onSurfaceChanged(final GL10 GL10, final int width, final int height) {
//猜想会根据gl 给出的宽高进行c++ 内部的更改逻辑
Cocos2dxRenderer.nativeOnSurfaceChanged(width, height);
}

@Override
public void onDrawFrame(final GL10 gl) {
/*
* 游戏每一帧渲染开始入口
* No need to use algorithm in default(60 FPS) situation,
* since onDrawFrame() was called by system 60 times per second by default.
*/
if (sAnimationInterval <= 1.0 / 60 * Cocos2dxRenderer.NANOSECONDSPERSECOND) {
//每一帧执行的入口。 所以会不会跟MainLoop 一样 ?
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) {
}
}
/*
* Render time MUST be counted in, or the FPS will slower than appointed.
*/
this.mLastTickInNanoSeconds = System.nanoTime();
Cocos2dxRenderer.nativeRender();
}
}

从点击icon 到Activity 的初始化,程序做了这么一些事情

  1. Activity 的onCreate
  2. Activity 的父类的 onCreate
  3. Activity 父类的 init()
  4. Cocos Render 对象的初始化
  5. Cocos Render 对象初始化以后 调用 nativeInit
  6. 每一帧调用c++ 部分的nativeRender

下面是nativeInit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
JNIEXPORT void Java_org_cocos2dx_lib_Cocos2dxRenderer_nativeInit(JNIEnv*  env, jobject thiz, jint w, jint h)
{
auto director = cocos2d::Director::getInstance();
auto glview = director->getOpenGLView();
if (!glview)
{
glview = cocos2d::GLViewImpl::create("Android app");
glview->setFrameSize(w, h);
director->setOpenGLView(glview);
cocos2d::Application::getInstance()->run(); // android 部分的 执行c++ 的Run()
}
else
{
cocos2d::GL::invalidateStateCache();
cocos2d::GLProgramCache::getInstance()->reloadDefaultGLPrograms();
cocos2d::DrawPrimitives::init();
cocos2d::VolatileTextureMgr::reloadAllTextures();
cocos2d::EventCustom recreatedEvent(EVENT_RENDERER_RECREATED);
director->getEventDispatcher()->dispatchEvent(&recreatedEvent);
director->setGLDefaultValues();
}
cocos2d::network::_preloadJavaDownloaderClass();
}

下面是nativeRender

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

"IOS 执行流程"

当IOS 和Android 部分执行到Mainloop , 启动的差异就已经被屏蔽了