使用python编写游戏修改器

最近比较怀旧,在玩一个比较老的PC游戏。由于游戏难度太高了,于是就打算自己写一个修改器。
通过查阅资料,在 Windows 下的修改器主要需要用到四个函数:OpenProcess, CloseHandle, WriteProcessMemory, ReadProcessMemory。

这几个都是C++的函数,在Python中可以通过ctypes来直接调用。
然后接下来介绍一下基本的操作流程。

1.通过 任务管理器 或者其他方式得到需要修改的游戏进程。然后通过 OpenProcess 注入该进程。

1
2
3
4
5
6
7
8
9
PROCESS_QUERY_INFORMATION = 0x0400
PROCESS_VM_OPERATION = 0x0008
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
hProcess = ctypes.windll.kernel32.OpenProcess(
PROCESS_QUERY_INFORMATION|PROCESS_VM_READ|PROCESS_VM_OPERATION|PROCESS_VM_WRITE,
False, pid
)

2.然后可以通过 ReadProcessMemory 来扫描游戏的内存找到需要修改的数值项的内存地址。

1
2
3
4
5
6
7
8
9
buf = ctypes.c_int32()
nread = ctypes.c_size_t()
ret = ctypes.windll.kernel32.ReadProcessMemory(
hProcess,
base_addr,
ctypes.byref(buf),
ctypes.sizeof(buf),
ctypes.byref(nread)
)

这里是读取 base_addr 地址之后的4个字节的内容。可以通过循环来遍历游戏的内存,找到需要修改的地址。
当然,为了方便也可以直接使用 Cheat Engine 之类的软件来查找,然后把找到的内存地址记录下来即可。

3.得到需要修改的内存地址之后,就可以 WriteProcessMemory 来修改该地址保存的值。

1
2
3
4
5
6
7
8
9
buf = ctypes.c_int32(value)
nread = ctypes.c_size_t()
ret = ctypes.windll.kernel32.WriteProcessMemory(
hProcess,
base_addr,
ctypes.byref(buf),
ctypes.sizeof(buf),
ctypes.byref(nwrite)
)

这里是往 base_addr 这个地址写入值为 value 的4字节内容。

4.最后如果不再需要修改了的话,就通过 CloseHandle 关闭该注入操作。

1
ctypes.windll.kernel32.CloseHandle(hProcess)

以上都是针对 Windows 系统的,对于 Linux 系统的话 可以通过 ptrace (http://man7.org/linux/man-pages/man2/ptrace.2.html) 操作实现。由于我没有 Linux 的游戏就没有研究了。

上面修改器的完整源代码,如有需要可通过以下链接获取:
https://github.com/wusuopu/cheat_engine_caesar3

使用pytest测试flask应用

python 本身就有 unittest 单元测试框架,但是觉得它并不是很好用,我更倾向于使用 pytest 。

下面通过一个例子来介绍如何使用 pytest 对 flask 应用进行单元测试。

首先新建一个 flask 应用,并针对根路径创建一条路由。代码如下:

1
2
3
4
5
6
# server.py
app = flask.Flask(__name__)
@app.route('/')
def home():
return 'ok'

然后针对首页编写单元测试,代码如下:

1
2
3
4
5
6
# tests/test_app.py
def test_home_page(client):
rv = client.get('/')
assert rv.status_code == 200
assert rv.data == b'ok'

然后执行命令运行该测试用例: pytest -s tests/test_app.py

在 pytest 中编写测试用例就只需要新建一个以 test_ 开头的函数即可。

以上是针对flask路由作的最基本测试。接下来编写一个新的路由,该页面只有用户登录之后才能访问。代码如下:

1
2
3
4
5
6
# server.py
@app.route('/member')
@flask_security.decorators.login_required
def member():
user = flask_security.core.current_user
return str(user.id)

要对该路由进行测试,则需要先创建一个用户。

1
2
3
4
5
6
7
8
9
# tests/test_app.py
def setup_module(module):
App.testing = True
fixture.setup()
def teardown_module(module):
"""
"""

上面的 setup_moduleteardown_module 函数分别是在所有的测试用例执行之前与执行之后执行。在这里我们通过 setup_module 在执行测试之前先创建一个用户。然后再创建一个 pytest 的 fixture:

1
2
3
4
5
6
7
8
# tests/conftest.py
@pytest.fixture
def auth_client(client):
with client.session_transaction() as sess:
sess['user_id'] = str(fixture.users[0].id)
yield client

这里创建了一个 auth_client fixture,之后所有以 auth_client 发起的请求都是登录状态的。

最后再针对 /member 路由编写两个测试用例,分别是未登录状态与登录状态下的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def test_member_page_without_login(client):
"""
没有登录则跳转到登录页面
"""
rv = client.get('/member')
assert rv.headers['Location'] == 'http://localhost/login?next=%2Fmember'
assert rv.status_code == 302
def test_member_page_with_login(auth_client):
"""
已经登录则返回当前用户id
"""
rv = auth_client.get('/member')
assert rv.status_code == 200
assert rv.data.decode('utf8') == str(fixture.users[0].id)

以上就是一个简单的 flask 应用了。但是有时一个稍微复杂一点的应用会用到一些第三方的api。这时针对这种情况编写测试用例时就需要用到 mock 功能了。再编写一个新的路由页面:

1
2
3
4
5
6
7
8
# server.py
@app.route('/movies')
def movies():
data = utils.fetch_movies()
if not data:
return '', 500
return flask.jsonify(data)

1
2
3
4
5
6
7
8
9
# utils.py
def fetch_movies():
try:
url = 'http://api.douban.com/v2/movie/top250?start=0&count=1'
res = requests.get(url, timeout=5)
return res.json()
except Exception as e:
return {}

请求该路由会返回豆瓣top250的电影信息。然后再编写两个测试用例分别模拟api调用成功与失败的情况。

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
# tests/test_app.py
def test_movies_api(client):
"""
调用豆瓣api成功的情况
"""
fetch_movies_patch = mock.patch('utils.fetch_movies')
func = fetch_movies_patch.start()
func.return_value = {'start': 0, 'count': 0, 'subjects': []}
rv = client.get('/movies')
assert rv.status_code == 200
assert func.called
fetch_movies_patch.stop()
def test_movies_api_with_error(client):
"""
调用豆瓣api出错的情况
"""
fetch_movies_patch = mock.patch('utils.fetch_movies')
func = fetch_movies_patch.start()
func.return_value = None
rv = client.get('/movies')
assert rv.status_code == 500
assert func.called
fetch_movies_patch.stop()

这里使用 python 的 mock 模块来模拟让某个函数返回固定的结果。

完整的代码请访问: https://github.com/wusuopu/flask-test-example

HAProxy+Nginx+gunicorn获取真实ip

之前在部署在 nginx + uwsgi 应用时都是通过如下方法来获取真实的客户端ip的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
upstream app_server {
server unix:///tmp/gunicorn.sock fail_timeout=0;
}
server {
listen 80;
server_name localhost;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_pass http://app_server;
}
}

这样在 uwsgi 的应用程序中只需要读取 http headers 中的 X-Forwarded-For 字段即可。

但是最近由于运维架构的是采用 haproxy + nginx + uwsgi 是形式,导致了在 uwsgi 应用程序中获取到的 ip 都是 haproxy 的。
为了要获取到真实的ip地址,需要由 haproxy 将 ip 传给 nginx,再由 nginx 传给 uwsgi。
在网上搜索了半天 haproxy 的相关配置,感觉太复杂了。因此还是决定从 nginx 入手。

经过实验将 nginx 的配置改为如下即可:

1
2
3
proxy_set_header X-Forwarded-For $http_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_redirect off;

在Docker中运行X11程序

如果是Linux系统的话,相对比较方便。先构建一个带gui各应的docker image,然后将
本机的X11 sock挂载到container内,
docker run -ti --rm -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix chrome

如果是mac OS系统的话,相对麻烦一些。

1.安装所需的软件:

1
2
brew install socat
brew cask install xquartz

2.依次运行刚刚安装的两个程序:

1
2
socat TCP-LISTEN:6000,reuseaddr,fork UNIX-CLIENT:\"$DISPLAY\"
open -a XQuartz

3.设置X11, XQuartz -> Preference -> Security -> Allow connections from network clients

4.docker run --rm -e DISPLAY=ifconfig | grep “inet\ “ | tail -1 | cut -d “ “ -f 2:0 chrome

对于已经启动了的container,可以在container内执行命令 export DISPLAY=<ip>:0 来设置 DISPLAY 从而使用本机的 X11 服务。

倘若当前你的 mac OS 没有连接网络,那么可能就没有ip地址供container内访问。
此时也许可以执行命令: sudo ifconfig lo0 alias 10.200.10.1/24 来手动设置一个ip。
然后在container内再设置 export DISPLAY=10.200.10.1:0

由octpress迁移到了hexo

这两天将博客的系统由原来的ruby octpress 迁移到了 nodejs hexo。

决定要进行迁移主要是两个原因:
1.使用 octpress 来生成页面感觉越来越慢了;
2.octpress 的页面样式表不知怎么的突然就坏掉了,整个页面显示都不正常了。这个是最主要的原因。

迁移的工作还算是比较顺利,只是体验了一下 hexo 感觉 bug 也不少。只得自己写些 patch,然后将就着用吧。

通过串口连接raspberry pi

最近在玩树莓派,有时没有网络,也没有显示器,此时如果想要连接树莓派执行一些操作的话会很麻烦。
因为之前玩过 ARM 的开发板编程,因此想能不能通过串口登录到 pi 呢。于是网上查了一下,还真的可以哦。
以下就作为备忘笔记记录一下操作过程。

1.首先需要一根 USB 转串口的线,如果没有的话可以去某宝上买一根吧,反正也不贵。我选的是 PL2303。
再根据系统以及芯片的不同而下载安装不同的驱动程序。 对于 mac OS 用户执行命令: ls /dev/ | grep tty.usb
如果驱动都安装正确的话应该是会有输出结果的。

2.然后在 pi 的系统上启用 serial。我安装的是 debian 系统,执行命令: sudo raspi-config
选择 advanced options -> serial 进行启用 serial。

3.串口连接

对于 Raspberry Pi3 的 GPIO 引脚如下:
pi3_gpio

串口线与 pi 的连接方式为: GND -> GND, RXD -> TXD, TXD -> RXD, 如图:
pi3-board

最后在电脑上使用串口连接软件进行连接,对应的串口设置为 115200 8N1,如图:
raspberry-pi-serial

参考资料: http://elinux.org/RPi_Serial_Connection

使用CodePush对ReactNative进行热更新

CodePush是微软提供的可用于对 Cordova 和 ReactNative 进行代码热更新的库。
在其官方的文档中已经写得很详细了,按照其说明来配置即可。我这里只是对在使用过程中遇到的一些坑作为总结。

创建应用

首先注册一个账号并创建一个 CodePush 的应用:

1
2
3
npm install -g code-push-cli
code-push register
code-push app add <appName>

安装配置CodePush

按照说明 https://github.com/Microsoft/react-native-code-push#getting-started,使用 rnpm 进行安装即可:

1
2
npm install --save react-native-code-push
rnpm link react-native-code-push

安装完成之后还需要再进行一些配置,对于 iOS 需要将 AppDelegate.m 文件中的 jsCodeLocation 修改为: jsCodeLocation = [CodePush bundleURL];
同时再在 Info.plist 文件中添加一项 CodePushDeploymentKey,其值为 CodePush 应用的 Deployment Key。

对于 android 需要在 MainActivity 类的 getPackages 方法中设置 Deployment Key。同时根据 ReactNative 的版本不同而使用不同的方法来设置 getJSBundleFile
参考: https://github.com/Microsoft/react-native-code-push#android-setup

程序更新

在安装、配置完成之后,即可以使用CodePush进行程序的更新操作了。
根据官方的说明只需要调用 CodePush.sync() 即可完成自动更新操作。
我针对自己的情况再进行封装了一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function syncCodePush() {
NetInfo.fetch().done(
(reach) => {
// 检查网络环境
if (_.includes(['wifi', 'WIFI', 'VPN'], reach)) {
CodePush.sync().done(
() => {
// 检查更新成功
},
(err) => {
// 更新失败
}
);
}
}
);
}

以上函数是保证只有在wifi的网络环境下才进行更新操作,同时由于 CodePush.sync() 返回的是一个 Promise 对象,
在这里我就遇到了由于网络异常而下载出错,从而导致 app 崩溃。因此需要处理 reject 的情况。

有时在程序更新之后的首次运行时可能会需要作一些迁移的操作,这里可以使用 getUpdateMetadata 来检查程序是不是首次运行。

1
2
3
4
5
6
7
Codepush.getUpdateMetadata().then(
(update) => {
if (update && update.isFirstRun) {
// 首次运行执行一些操作
}
}
).done( callback );

发布更新

在 app 发布安装包发布出去之后,已经有用户下载安装了。此时如果再有 js 代码更新或者图片文件的改动的话,可以使用 CodePush 进行发布。
进入 ReactNative 的项目根目录,使用 code-push 命令进行发布更新。例如:

1
code-push release-react DemoApp ios -m -d Staging --des "更新描述" -t "~2.0.0"

以上命令是发布一个紧急更新到 Staging ,只有 ios appp 的版本为 2.0~3.0 的才会下载该更新包。
由于是紧急更新,app在下载安装完成之后会自动重启应用该更新包。否则的话就需要用户下次手动启动app时该更新包才会生效。

在 CodePush 中针对 ios 和 android 可以共用一个应用,只是我个人感觉这样在管理 deployment history 时不太方便。
因此我通常会创建两个应用,例如: DemoApp-iOS、DemoApp-Android 这样的。

需要注意的是,由于 CodePush 的 server 是在国外,因此下载的速度会比较慢。

最后我自己使用 Electron + Vue.js 开发了一个 CodePush 的简易管理工具,https://github.com/wusuopu/code-push-gui
可以对 CodePush 的 app 跟 deployment 进行简单的管理。

ReactNative获取设备屏幕尺寸

在做移动开发过程中,有时我们需要适配不同尺寸大小的屏幕。这里我们就需要到获取设备屏幕的大小。
由于我们是使用的 ReactNative 来开发手机 app,这里就介绍一下在 ReactNative 中如何获取到设备屏幕的分辨率的。
也算是对之前踩坑的总结吧。

在此之前需要先了解 ReactNative 中的尺寸计算单位,它并不是使用的px。http://facebook.github.io/react-native/releases/next/docs/pixelratio.html

使用 Dimensions 模块

在 ReactNative 中有一个 Dimensions 模块,通过它可以获取当前设备的屏幕分辨率。
参考: http://facebook.github.io/react-native/releases/next/docs/dimensions.html

1
var {height, width} = Dimensions.get('window');

刚开始时我也是使用这种方法来得到整个屏幕的分辨率的,感觉轻松搞定。然而这里面却有一个坑。

首先来看看 ios 和 android 中的界面结构:

ios-screen-struct
android-screen-struct

如图所示,屏幕的宽度计算比较简单,就是从左边到右边的距离即可。
然后就是屏幕的高度了,这里其实我们是需要获取到可用区域的高度。
如图所示,对于 ios 系统来说可用区域高度就是整个屏幕的高度减去 Status Bar 的高度;
对于 android 系统来说就是屏幕的高度减去 Status Bar 和 Soft Menu Bar 的高度。

获取 iOS 设备的屏幕分辨率

正如上面所说的,在 ios 下的计算方法为:

1
2
var WIDTH = Dimensions.get('window').width;
var HEIGHT = Dimensions.get('window').height - STATUS_BAR_HEIGHT;

在 ios 系统状态栏高度(STATUS_BAR_HEIGHT)通常为 20。
不过如果你设置了隐藏状态栏的话,那么 STATUS_BAR_HEIGHT 则为0。

以上是手机竖屏的情况,在横屏状态下则交换两个值:

1
2
var LANDSCAPE_WIDTH = HEIGHT + STATUS_BAR_HEIGHT;
var LANDSCAPE_HEIGHT = WIDTH - STATUS_BAR_HEIGHT;

到了这里虽然麻烦了一点,但是总体来说也还好。问题都解决了。
感觉生活是如此美好啊,然而这个世界上却还有一个系统名为 android。
它有着数不清种类屏幕大小的设备,然后瞬间感觉整个人都不好了。

获取 Android 设备的屏幕分辨率

如果按照 ios 下的方法来做,获取到 WIDTH 是没有问题,
但是 HEIGHT 的话还需要减去 Status Bar 和 Soft Menu Bar 的高度。
因此我们还需要获取到状态栏的高度(STATUS_BAR_HEIGHT)和虚拟按钮的高度(SOFT_MENU_BAR_HEIGHT)。

这里我们使用react-native-extra-dimensions-android这个库。

1
2
3
4
5
6
const ExtraDimensions = require('react-native-extra-dimensions-android');
const STATUS_BAR_HEIGHT = ExtraDimensions.get('STATUS_BAR_HEIGHT');
const SOFT_MENU_BAR_HEIGHT = ExtraDimensions.get('SOFT_MENU_BAR_HEIGHT');
const WIDTH = ExtraDimensions.get('REAL_WINDOW_WIDTH');
const HEIGHT = ExtraDimensions.get('REAL_WINDOW_HEIGHT') - STATUS_BAR_HEIGHT - SOFT_MENU_BAR_HEIGHT;

以上是设备竖屏的结果,在横屏下你以为是不是只需要交换两个值就搞定了呢。

1
2
var LANDSCAPE_WIDTH = HEIGHT + STATUS_BAR_HEIGHT;
var LANDSCAPE_HEIGHT = WIDTH - STATUS_BAR_HEIGHT;

Naive!!如果这么轻松就搞定了的话,android就不叫做android了。

先来看看下面两张图片吧,分别是一个android平板设备在横屏和竖屏状态下的截图:

android-pad-landscape
android-pad-portrait

你把你手中的 android 手机分别进入横屏和竖屏状态下,再对照上面两张图片你会发现什么。
没错的,在平板设备上屏幕旋转之后 Soft Menu Bar 也跟着旋转了,
而在手机设备上 Soft Menu Bar 是始终固定在手机底部的。

尼玛,太坑爹了。看到这里瞬间呕血三升,要适配手机跟平板实现是太麻烦了。
以下是我的解决办法,先检查当前设备是否为平板,然后再分别处理。
至于平板的判断方法就自己想办法了,我的方法也不一定准。

1
2
3
4
5
6
7
if (isPad) {
LANDSCAPE_WIDTH = HEIGHT + STATUS_BAR_HEIGHT + SOFT_MENU_BAR_HEIGHT;
LANDSCAPE_HEIGHT = WIDTH - STATUS_BAR_HEIGHT - SOFT_MENU_BAR_HEIGHT;
} else {
LANDSCAPE_WIDTH = HEIGHT + STATUS_BAR_HEIGHT;
LANDSCAPE_HEIGHT = WIDTH - STATUS_BAR_HEIGHT;
}

以上是在 android 下遇到的第一个大坑。

到了这里我以为一切都该结束了吧,然而没想到还有一种手机叫做魅族。如下图:

Mezu-smart-bar

在屏幕右下角其实是有一个按钮的,然而左图所示的,该按钮被魅族手机的 Smart Bar 遮住了。
进入系统设置将 Smart Bar 隐藏后效果如右图所示。

看到这里又吐了两口老血,此为第二个大坑。

1
2
3
4
5
6
7
8
9
10
11
12
13
const SMART_BAR_HEIGHT = ExtraDimensions.get('SMART_BAR_HEIGHT');
if (SMART_BAR_HEIGHT) {
HEIGHT -= SMART_BAR_HEIGHT;
}
....
// 在上面 LANDSCAPE 的计算结果上再作如下处理
if (SMART_BAR_HEIGHT) {
LANDSCAPE_WIDTH += SMART_BAR_HEIGHT;
LANDSCAPE_HEIGHT -= SMART_BAR_HEIGHT;
}

在魅族的手机上计算屏幕高度时还需要再减去 Smart Bar 的高度,
同时还需要注意的是, Smart Bar 跟平板上的Soft Menu Bar 一样会随着屏幕旋转而转动的。

最后还有一点需要注意的是,在安装 react-native-extra-dimensions-android 库时不能直接使用 npm install --save react-native-extra-dimensions-android 进行安装,
而是需要直接通过 git 仓库来安装: npm install --save git+https://github.com/jaysoo/react-native-extra-dimensions-android.git

因为在 npm 上 react-native-extra-dimensions-android 的最新版为 0.17.0,
SMART_BAR_HEIGHT 的功能是在此之后才添加进来的。

在库的版本上面又被坑了一下。

ReactNative jsbundle管理

上一篇文章中介绍了 RN(ReactNative) 自动设置 development server IP 的方法。
这在开发过程中方便了不少,然而我在想能否更加方便一些呢。首先我们知道在开发 RN 应用时,jsbundle 有两种加载方式。
第一种是指定 url 通过网络进行加载;第二种是 pre-bundled 将 jsbundle 文件打包进 app 安装包中。
编译生成的安装包有 Debug 和 Release 两种模式,在 Debug 模式下默认是使用第一种方式加载 jsbundle,在 Release 模式下默认是使用第二种方式。

现在我的需求是编译生成三种模式的安装包:Debug、Release 和 Stage。前两种跟之前一样,
而 Stage 模式下是使用第二种方式加载 jsbundle, 但是生成的 jsbundle 是 DEV 状态下的。
这样在开发过程中给他人安装app进行测试时就不需要反复的修改配置了。

修改 Android 的配置

对于的 android 的配置比较简单。只需修改 android/app/build.gradle 文件,新添加一个 buildTypes 即可。

apply from: "react.gradle" 之前添加如下内容:

1
2
3
project.ext.react = [
bundleInStage: true
]

然后再修改配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
android {
buildTypes {
debug {
applicationIdSuffix ".debug"
resValue 'string', 'app_name', '"XXXX(Debug)"'
......
}
release {
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
resValue 'string', 'app_name', '"XXXX"'
......
}
stage {
initWith(buildTypes.debug)
applicationIdSuffix ".stage"
resValue 'string', 'app_name', '"XXXX(Stage)"'
}
}
}

这里添加了一个 stage buildTypes 继承至 debug。并且为了能够同时安装不同模式下的app,这里设置了不同模式 bundleID 的后缀。
同时还设置了不同模式下app的名称,以便区分。

然后进行 android 目录下执行 ./gradlew assemble 命令,即可在 android/app/build/outputs/apk 目录生成 app-debug.apk、app-release.apk 和 app-stage.apk 三个 apk 包。

修改 iOS 的配置

首先将 Debug Configuration 复制为 Stage
Xcode-configuration

然后进入 Build Settings 修改 Preprocessor Macros,对 Stage 添加一项配置: STAGE=1
Xcode-buildSettings

然后再编辑 AppDelegate.m 文件,修改 jsCodeLocation 相关配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#if STAGE
#warning "STAGE"
jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#elif DEBUG
#if TARGET_OS_SIMULATOR
#warning "DEBUG SIMULATOR"
jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/index.ios.bundle?platform=ios&dev=true"];
#else
#warning "DEBUG DEVICE"
NSString *serverIP = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"SERVER_IP"];
NSString *jsCodeUrlString = [NSString stringWithFormat:@"http://%@:8081/index.ios.bundle?platform=ios&dev=true", serverIP];
NSString *jsBundleUrlString = [jsCodeUrlString stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
jsCodeLocation = [NSURL URLWithString:jsBundleUrlString];
#endif
#else
#warning "PRODUCTION DEVICE"
jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif

为了能够同时安装多个应用,还需要设置各个模式下的 Bundle Identifier。进入 Build Settings -> Packaging -> Product Bundle Identifier
Xcode-bundleID

为了便于区分,最好给各个模式下的应用设置不同的AppName。进入 Build Settings -> User-Defined,添加一项设置
Xcode-User-Defined

然后再进入 Info,设置 CFBundleDisplayName 的值为 $(BUNDLE_DISPLAY_NAME)

至此,配置已经修改完成。如果之前有使用 cocoapods 安装过第三方库的话,那么可能还需要再重新安装一遍。

ReactNative自动设置开发服务器IP

在开发 ReactNative 应用时,jsbundle 有两种加载方式。第一种是指定 url 通过网络进行加载;第二种是 pre-bundled 将 jsbundle 文件打包进 app 安装包中。

以下就是创建项目之后 ios 的默认配置。

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
/**
* Loading JavaScript code - uncomment the one you want.
*
* OPTION 1
* Load from development server. Start the server from the repository root:
*
* $ npm start
*
* To run on device, change `localhost` to the IP address of your computer
* (you can get this by typing `ifconfig` into the terminal and selecting the
* `inet` value under `en0:`) and make sure your computer and iOS device are
* on the same Wi-Fi network.
*/
jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/index.ios.bundle?platform=ios&dev=true"];
/**
* OPTION 2
* Load from pre-bundled file on disk. The static bundle is automatically
* generated by the "Bundle React Native code and images" build step when
* running the project on an actual device or running the project on the
* simulator in the "Release" build configuration.
*/
// jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];

这里有个麻烦的地方就是,当我在真机设备上调试时。每次都需要执行 ifconfig 命令,然后将 localhost 修改为我的 ip 地址。并且在使用 git 进行代码管理时,一不小心将修改后的文件提交上去了,其他同事在 pull 时又会与自己的冲突。
最终实在忍受不了了,在想能不能编译时自动获取到本机的 ip 呢,这样就不用每次都手动修改了。于是找到了这篇文章: http://moduscreate.com/automated-ip-configuration-for-react-native-development/
我这里参考了他的方案并做了一点小调整。

按照他的步骤,首先添加 Run Script。
在 Xcode 中选择“Build Phases”,然后点击左上角的”+”选择“New Run Script Phase”。
在列表最后出现了“Run Script”,将其展开,然后编辑代码块的内容:

1
2
3
4
5
6
INFOPLIST="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"
echo "writing to $INFOPLIST"
PLISTCMD="Add :SERVER_IP string $(ifconfig | grep inet\ | tail -1 | cut -d " " -f 2)"
echo -n "$INFOPLIST" | xargs -0 /usr/libexec/PlistBuddy -c "$PLISTCMD" || true
PLISTCMD="Set :SERVER_IP $(ifconfig | grep inet\ | tail -1 | cut -d " " -f 2)"
echo -n "$INFOPLIST" | xargs -0 /usr/libexec/PlistBuddy -c "$PLISTCMD" || true

第二步编辑 AppDelegate.m 文件。
将项目默认生成的 jsCodeLocation 配置删除掉,并添加代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#if DEBUG
#if TARGET_OS_SIMULATOR
#warning "DEBUG SIMULATOR"
jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/index.ios.bundle?platform=ios&dev=true"];
#else
#warning "DEBUG DEVICE"
NSString *serverIP = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"SERVER_IP"];
NSString *jsCodeUrlString = [NSString stringWithFormat:@"http://%@:8081/index.ios.bundle?platform=ios&dev=true", serverIP];
NSString *jsBundleUrlString = [jsCodeUrlString stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
jsCodeLocation = [NSURL URLWithString:jsBundleUrlString];
#endif
#else
#warning "PRODUCTION DEVICE"
jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif

这里如果在模拟器中进行调试,那么 development server 则为 localhost;如果在真机设备中调试,那么 development server 则为电脑的 ip 地址。
到此已经可以实现自动设置 ip 地址了,如果还想要在 Chrome 中对设备进行调试,那么还需要修改一下 WebSocket 的配置。

第三步编辑 RCTWebSocketExecutor.m 文件。
在 Xcode 中打开 -> Libraries -> RCTWebSocket.xcodeproj -> RCTWebSocketExecutor.m 文件,大概在文件 53 行左右的位置,将 NSString *URLString = [NSString stringWithFormat:@"http://localhost:%zd/debugger-proxy?role=client", port]; 修改为:

1
2
3
4
5
6
#if TARGET_OS_SIMULATOR
NSString *serverIP = @"localhost";
#else
NSString *serverIP = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"SERVER_IP"];
#endif
NSString *URLString = [NSString stringWithFormat:@"http://%@:%zd/debugger-proxy?role=client", serverIP, port];

现在配置已经完成了,接下来就试试看是否有效吧。

经过修改之后相对于之前已经方便了不少,只是我还遇到一个问题。那就是我的 MacBook 在办公室时的 ip 跟在家里的 ip 是不同的。
这样的话每次切换环境都需要重新编译一下应用,还是有点麻烦。于是乎我自己将第一步的脚本作了下修改,新的内容如下:

1
2
3
4
5
6
INFOPLIST="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"
echo "writing to $INFOPLIST"
PLISTCMD="Add :SERVER_IP string $(hostname)"
echo -n "$INFOPLIST" | xargs -0 /usr/libexec/PlistBuddy -c "$PLISTCMD" || true
PLISTCMD="Set :SERVER_IP $(hostname)"
echo -n "$INFOPLIST" | xargs -0 /usr/libexec/PlistBuddy -c "$PLISTCMD" || true

这里我使用 hostname 来作为 development server 的地址,而不是 ip。这样的话即便是网络环境发生了变化,只要手机设备跟电脑处于同一个局域网内就不需要再重新编译应用了。