CrashMonkey4iOS 已经出来一段时间了,最近有位同样做移动测试的朋友在尝试使用它的过程中遇到了些问题,过来问我,因此我也顺便把这个工具试用了一下。

安装过程:

1、把代码库 clone 下来:

git clone https://github.com/vigossjjj/CrashMonkey4IOS.git

2、执行里面的 reset.sh 文件进行环境配置:

chmod +x reset.sh && ./reset.sh

gem already installed ruby library erubis.
install depends libimobiledevice...
==> Installing dependencies for libimobiledevice: libtasn1, libxml2, libplist, libusb, usbmuxd, op
==> Installing libimobiledevice dependency: libtasn1
==> Downloading https://homebrew.bintray.com/bottles/libtasn1-4.5.yosemite.bottle.tar.gz
######################################################################## 100.0%
==> Pouring libtasn1-4.5.yosemite.bottle.tar.gz
🍺  /usr/local/Cellar/libtasn1/4.5: 56 files, 572K
==> Installing libimobiledevice dependency: libxml2
==> Downloading https://homebrew.bintray.com/bottles/libxml2-2.9.2.yosemite.bottle.tar.gz ######################################################################## 100.0%
==> Pouring libxml2-2.9.2.yosemite.bottle.tar.gz
==> Caveats
This formula is keg-only, which means it was not symlinked into /usr/local.

Mac OS X already provides this software and installing another version in
parallel can cause all kinds of trouble.

Generally there are no consequences of this for you. If you build your
own software and it requires this formula, you'll need to add to your
build variables:

    LDFLAGS:  -L/usr/local/opt/libxml2/lib
    CPPFLAGS: -I/usr/local/opt/libxml2/include

==> Summary
🍺  /usr/local/Cellar/libxml2/2.9.2: 275 files, 11M
==> Installing libimobiledevice dependency: libplist
==> Downloading https://homebrew.bintray.com/bottles/libplist-1.12.yosemite.bottle.tar.gz
######################################################################## 100.0%
==> Pouring libplist-1.12.yosemite.bottle.tar.gz
🍺  /usr/local/Cellar/libplist/1.12: 26 files, 412K
==> Installing libimobiledevice dependency: libusb
==> Downloading https://homebrew.bintray.com/bottles/libusb-1.0.19.yosemite.bottle.1.tar.gz
######################################################################## 100.0%
==> Pouring libusb-1.0.19.yosemite.bottle.1.tar.gz
🍺  /usr/local/Cellar/libusb/1.0.19: 11 files, 368K
==> Installing libimobiledevice dependency: usbmuxd
==> Downloading https://homebrew.bintray.com/bottles/usbmuxd-1.0.10.yosemite.bottle.1.tar.gz
######################################################################## 100.0%
==> Pouring usbmuxd-1.0.10.yosemite.bottle.1.tar.gz
🍺  /usr/local/Cellar/usbmuxd/1.0.10: 11 files, 156K
==> Installing libimobiledevice dependency: openssl
==> Downloading https://homebrew.bintray.com/bottles/openssl-1.0.2a-1.yosemite.bottle.1.tar.gz
######################################################################## 100.0%
==> Pouring openssl-1.0.2a-1.yosemite.bottle.1.tar.gz
==> Caveats
A CA file has been bootstrapped using certificates from the system
keychain. To add additional certificates, place .pem files in
  /usr/local/etc/openssl/certs

and run
  /usr/local/opt/openssl/bin/c_rehash

This formula is keg-only, which means it was not symlinked into /usr/local.

Mac OS X already provides this software and installing another version in
parallel can cause all kinds of trouble.

Apple has deprecated use of OpenSSL in favor of its own TLS and crypto libraries

Generally there are no consequences of this for you. If you build your
own software and it requires this formula, you'll need to add to your
build variables:

    LDFLAGS:  -L/usr/local/opt/openssl/lib
    CPPFLAGS: -I/usr/local/opt/openssl/include

==> Summary
🍺  /usr/local/Cellar/openssl/1.0.2a-1: 463 files, 18M
==> Installing libimobiledevice
==> Downloading https://homebrew.bintray.com/bottles/libimobiledevice-1.2.0.yosemite.bottle.tar.gz
######################################################################## 100.0%
==> Pouring libimobiledevice-1.2.0.yosemite.bottle.tar.gz
🍺  /usr/local/Cellar/libimobiledevice/1.2.0: 64 files, 1.2M
install depends libimobiledevice done.
imagemagick already install.
upgrade imagemagick...
upgrade imagemagick done.

(实际上就是安装三个依赖库:ruby 的 erubis,还有 libimobiledeviceimagemagick,至于为何没有它文档上介绍的 ideviceinstaller 原因不是很清楚)

使用方法

查看帮助:

$ bin/smart_monkey -h
Usage: smart_monkey [options]
    -a app_name                      Bundle ID of the desired target on device(Required)
    -w device                        Target Device UDID(Required)
    -n run_count                     How many times monkeys run(default: 1)
    -d result_dir                    Where to output result(default: ./smart_monkey_result)
    -t time_limit_sec                Time limit of running
    -s dsym_file                     Use .dSYM file to symbolicating crash logs
    -c custom_path                   Configuration custom.js Path
    -e extend_javascript_path        Extend Uiautomation Javascript for such Login scripts
        --compress-result compress_rate
                                     compress the screenshot images to save disk space!(example: 50%)
        --detail-count detail_event_count
                                     How many events to show in detail result page(default 50)
        --show-config                Show Current Configuration custom.js
        --drop-useless-img           Delete the un-displayed images of detial page.
        --list-app                   Show List of Installed Apps in iPhone/iPhone Simulator
        --list-devices               Show List of Devices
        --reset-iPhone-Simulator     Reset iPhone Simulator
        --version                    print smart monkey version

内容有点多,但对于启动 monkey 测试最关键是两个参数:-a app_name-w device

-a appname:app 的 bundle id,可在 app 源码的项目信息中看到(Bundle Identifier)。由于是直接通过 bundle id 来启动 app ,因此前置条件是 app 已经安装在被测设备上(包括模拟器)

-w device:被测设备的 UDID 。 CrashMonkey4iOS 支持 Simulator 和 真实设备 。需要使用模拟器时使用的 UDID 可以通过 bin/smart_monkey --list-devices 获取(严格来说不能算 UDID ,只能叫模拟器的 identifier,但为了保持统一后文还是称为 UDID)。例如我的输出为:

$ bin/smart_monkey --list-devices
...
iPhone 5s (7.1 Simulator) [A5D60D43-E673-4DB6-ADD6-1EB59ABDD97D]
iPhone 5s (8.1 Simulator) [DC34010F-69D8-4A29-B771-A90F15D71A58]
iPhone 5s (8.3 Simulator) [78E825CE-261A-4A86-8A8B-7466EDD0F564]
iPhone 6 (8.1 Simulator) [30874B86-ACA0-4B79-AA04-171FE8C6BB84]
iPhone 6 (8.3 Simulator) [E23DE92A-B6B1-4AEF-9632-4DA8DD7CE630]
iPhone 6 Plus (8.1 Simulator) [0FD26636-D91F-47C5-8649-1D4E8F3AB8ED]
iPhone 6 Plus (8.3 Simulator) [D8426A6C-82F0-4245-A003-8E63057EEBED]

此时 模拟器后面的一长串数字 + 字母 就是模拟器的 UDID ,例如 "iPhone 6 (8.3 Simulator)" 的 UDID 为 E23DE92A-B6B1-4AEF-9632-4DA8DD7CE630中间的破折号不能省略

此外,由于 CrashMonkey 没有集成自动启动模拟器的功能,因此使用的前置条件是 被测设备(包括模拟器)已经启动完毕并停留在桌面,如处在锁屏界面请先解锁

使用示例

我自己有一个应用 bundle id 为 chj.ToDoList,我想在 iPhone 6 (8.3 Simulator) 上运行这个应用,下面是完整的执行过程。

1、通过 bin/smart_monkey --list-app 能看到 ToDoList 这个应用:

$ bin/smart_monkey --list-app
============For iPhone Simulator:
AwesomeProject.app
HCCB_app.app
HCCB_score.app
PhonegapDemo.app
ToDoList.app
chjReactNativeProject.app
chjapp.app
ruby-china-for-ios.app
webViewDemo.app
============For iPhone Device:
...

此处可以看到有 ToDoList.app。这个 app 文件在所有模拟器中通用,不需要再考虑它是在具体哪个模拟器上。
若没有出现,则想办法让这个应用在模拟器上跑一次。

2、通过 bin/smart_monkey --list-devices 看到的设备列表中有你想使用的设备,如果是模拟器则该模拟器必须处在打开状态(已经能看到模拟器里的桌面)

执行命令 bin/smart_monkey -a chj.ToDoList -w E23DE92A-B6B1-4AEF-9632-4DA8DD7CE630

$ bin/smart_monkey -a chj.ToDoList -w E23DE92A-B6B1-4AEF-9632-4DA8DD7CE630

INSTRUMENTS_TRACE_PATH : /Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/*.trace
RESULT_BASE_PATH : /Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/smart_monkey_result
{:app_path=>"chj.ToDoList", :device=>"E23DE92A-B6B1-4AEF-9632-4DA8DD7CE630", :run_count=>1, :time_limit_sec=>nil, :detail_event_count=>50}
=================================== Start Test (1/1) =======================================
2015-06-07 22:59:14.442 instruments[7163:208626] WebKit Threading Violation - initial use of WebKit from a secondary thread.
Attempting iOS Simulator system log capture via tail system.log.
BundleID was found: chj.ToDoList
Run: ["instruments", "-w", "E23DE92A-B6B1-4AEF-9632-4DA8DD7CE630", "-t", "/Applications/Xcode.app/Contents/Applications/Instruments.app/Contents/PlugIns/AutomationInstrument.xrplugin/Contents/Resources/Automation.tracetemplate", "chj.ToDoList", "-e", "UIASCRIPT", "/Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/smart_monkey_result/report_20150607225913/custom.js", "-e", "UIARESULTSPATH", "/Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/smart_monkey_result/report_20150607225913"]
2015-06-07 22:59:16.176 instruments[7177:208756] WebKit Threading Violation - initial use of WebKit from a secondary thread.
2015-06-07 14:59:24 +0000 Default: {"width":375,"height":667}
2015-06-07 14:59:24 +0000 Debug: target.tapWithOptions({x:"243.7460767105222", y:"184.1347451447509"}, {touchCount:"1", tapCount:"1", duration:"0"})
2015-06-07 14:59:24 +0000 Debug: target.captureRectOnScreenWithName("{origin:{x:0.00,y:0.00}, size:{height:667.00,width:375.00}}", UIScreen, "monkey-2015-06-07T14-59-24-203Z")
2015-06-07 14:59:24 +0000 Screenshot captured.
2015-06-07 14:59:24 +0000 Debug: target.tapWithOptions({x:"68.52816045284271", y:"506.7851242637262"}, {touchCount:"1", tapCount:"1", duration:"0"})
2015-06-07 14:59:24 +0000 Debug: target.captureRectOnScreenWithName("{origin:{x:0.00,y:0.00}, size:{height:667.00,width:375.00}}", UIScreen, "monkey-2015-06-07T14-59-24-447Z")
2015-06-07 14:59:24 +0000 Screenshot captured.
...
2015-06-07 14:59:42 +0000 Debug: MonkeyTest::ButtonHandler(CloseX,3,true,): 0
2015-06-07 14:59:42 +0000 Debug: MonkeyTest::ButtonHandler(确定,3,false,): 0
2015-06-07 14:59:42 +0000 Debug: MonkeyTest finish.
Instruments Trace Complete (Duration : 26.255495s; Output : /Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/instrumentscli0.trace)
Stop iOS system log capture.
2015-06-07 22:59:50.578 instruments[7194:209328] WebKit Threading Violation - initial use of WebKit from a secondary thread.
2015-06-07 22:59:54.966 instruments[7212:209396] WebKit Threading Violation - initial use of WebKit from a secondary thread.
2015-06-07 22:59:55.800 instruments[7216:209428] WebKit Threading Violation - initial use of WebKit from a secondary thread.
2015-06-07 22:59:56.653 instruments[7221:209477] WebKit Threading Violation - initial use of WebKit from a secondary thread.
2015-06-07 22:59:57.468 instruments[7225:209502] WebKit Threading Violation - initial use of WebKit from a secondary thread.
Monkey Test Report:/Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/smart_monkey_result/report_20150607225913/index.html
EXIT 0

此时会看到被测应用在模拟器上被启动,然后被随机事件操作,最后自动退出。

测试报告默认放在 bin/smart_monkey_result中,具体位置在测试命令最后输出的信息中可以找到,例如上面的例子中测试报告位置为: Monkey Test Report:/Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/smart_monkey_result/report_20150607225913/index.html

测试报告本身非常清晰,有足够的 log(system log,instruments log,如果 crash 还有 crash log)以及操作步骤截图。其中截图还包含了操作指示,例如:

发现的问题及解决方案

以下问题均已在 github 上建立 issue。也希望有发现存在其他问题的同学遵照原作者的指示统一在 github 上报 issue 。

1、由于 reset.sh 没有安装 ideviceinstaller ,而使用 bin/smart_monkey --list-app 时查看真机上的应用列表需要调用这个依赖项,所以会出现如下错误:

============For iPhone Device:
/Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/lib/smart_monkey/monkey_runner.rb:175:in ``': No such file or directory - ideviceinstaller (Errno::ENOENT)
    from /Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/lib/smart_monkey/monkey_runner.rb:175:in `list_app'
    from /Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/lib/smart_monkey/monkey_runner.rb:24:in `run'
    from ./smart_monkey:49:in `<main>'

解决方法:手动运行 brew install ideviceinstaller 来安装这个依赖项
现在已经换成更友好的错误提示,会直接提示安装 ideviceinstaller 。

2、这个严格来说其实是 bug 。通过命令行信息我们可以看出实际上执行的是一条 instrument 命令,通过 google 得知 instrument 命令的 -w 参数支持使用类似 -w "iPhone 6 (8.3 Simulator)" 的方式来选择设备,但在 smart_monkey 中使用这种写法会出现如下错误:

=================================== Start Test (1/1) =======================================
sh: -c: line 0: syntax error near unexpected token `('
sh: -c: line 0: `"instruments" -s devices | grep iPhone 6 (8.3 Simulator)'
sh: -c: line 0: syntax error near unexpected token `('
sh: -c: line 0: `idevicecrashreport -u iPhone 6 (8.3 Simulator) -e -k /Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/smart_monkey_result/report_20150607225716/crash_1'
sh: -c: line 0: syntax error near unexpected token `('
sh: -c: line 0: `"instruments" -s devices | grep iPhone 6 (8.3 Simulator)'
Attempting iOS device system log capture via deviceconsole.
BundleID was found: chj.ToDoList
Run: ["instruments", "-w", "iPhone 6 (8.3 Simulator)", "-t", "/Applications/Xcode.app/Contents/Applications/Instruments.app/Contents/PlugIns/AutomationInstrument.xrplugin/Contents/Resources/Automation.tracetemplate", "chj.ToDoList", "-e", "UIASCRIPT", "/Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/smart_monkey_result/report_20150607225716/custom.js", "-e", "UIARESULTSPATH", "/Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/smart_monkey_result/report_20150607225716"]
Stop iOS system log capture.
sh: -c: line 0: syntax error near unexpected token `('
sh: -c: line 0: `"instruments" -s devices | grep iPhone 6 (8.3 Simulator)'
sh: -c: line 0: syntax error near unexpected token `('
sh: -c: line 0: `idevicecrashreport -u iPhone 6 (8.3 Simulator) -e -k /Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/smart_monkey_result/report_20150607225716/crash_1'
sh: -c: line 0: syntax error near unexpected token `('
sh: -c: line 0: `"instruments" -s devices | grep iPhone 6 (8.3 Simulator)'
sh: -c: line 0: syntax error near unexpected token `('
sh: -c: line 0: `ideviceinfo -u iPhone 6 (8.3 Simulator) -k ProductType'
sh: -c: line 0: syntax error near unexpected token `('
sh: -c: line 0: `"instruments" -s devices | grep iPhone 6 (8.3 Simulator)'
sh: -c: line 0: syntax error near unexpected token `('
sh: -c: line 0: `ideviceinfo -u iPhone 6 (8.3 Simulator) -k ProductVersion'
sh: -c: line 0: syntax error near unexpected token `('
sh: -c: line 0: `"instruments" -s devices | grep iPhone 6 (8.3 Simulator)'
sh: -c: line 0: syntax error near unexpected token `('
sh: -c: line 0: `ideviceinfo -u iPhone 6 (8.3 Simulator) -k DeviceName'
Monkey Test Report:/Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/smart_monkey_result/report_20150607225716/index.html
EXIT 1

由于没有被引号包围以表示这是字符串, iPhone 6 (8.3 Simulator) 被 shell 识别为错误语法,因此出现了不少错误。

解决方法:不要使用模拟器名称,使用 UDID 。 已 fix

3、使用模拟器运行过程中出现了一次锁屏操作,结果 CrashMonkey 无法自行解锁:

2015-06-07 15:01:47 +0000 Debug: target.flickFromTo({x:"55.99269334925339", y:"628.6212073885836"}, {x:"284.4106543343514", y:"408.6799378818832"})
2015-06-07 15:01:47 +0000 Debug: target.captureRectOnScreenWithName("{origin:{x:0.00,y:0.00}, size:{height:667.00,width:375.00}}", UIScreen, "monkey-2015-06-07T15-01-47-423Z")
2015-06-07 15:01:47 +0000 Screenshot captured.
2015-06-07 15:01:47 +0000 Debug: target.lockForDuration("0.3549308124929667")
2015-06-07 15:01:49 +0000 Debug: target.systemApp().mainWindow().scrollViews().firstWithPredicate("ANY elements.name == 'SlideToUnlock' OR ANY elements.name == 'SlideToSetup' OR ANY elements.name == 'Passcode field'").dragInsideWithOptions({endOffset:{x:0.90,y:0.90}}, duration:"0.5", startOffset:{x:0.20,y:0.90}}})
2015-06-07 15:01:52 +0000 Debug: Unlock failed. Retrying up to 2 more time(s).
2015-06-07 15:01:52 +0000 Debug: target.systemApp().mainWindow().scrollViews().firstWithPredicate("ANY elements.name == 'SlideToUnlock' OR ANY elements.name == 'SlideToSetup' OR ANY elements.name == 'Passcode field'").dragInsideWithOptions({endOffset:{x:0.90,y:0.90}}, duration:"0.5", startOffset:{x:0.20,y:0.90}}})
2015-06-07 15:01:53 +0000 Debug: Unlock failed. Retrying up to 1 more time(s).
2015-06-07 15:01:53 +0000 Debug: target.systemApp().mainWindow().scrollViews().firstWithPredicate("ANY elements.name == 'SlideToUnlock' OR ANY elements.name == 'SlideToSetup' OR ANY elements.name == 'Passcode field'").dragInsideWithOptions({endOffset:{x:0.90,y:0.90}}, duration:"0.5", startOffset:{x:0.20,y:0.90}}})
2015-06-07 15:01:55 +0000 Debug: target.captureRectOnScreenWithName("{origin:{x:0.00,y:0.00}, size:{height:667.00,width:375.00}}", UIScreen, "monkey-2015-06-07T15-01-55-572Z")
2015-06-07 15:01:55 +0000 Screenshot captured.
2015-06-07 15:01:55 +0000 Warning: Target app go to outside, trigger re-launch action.

然后程序就会一直停留在此处,而模拟器也不会再有任何反应。

解决方法:人工滑动屏幕解锁,然后 CrashMonkey 会自动继续执行下去。 修改 lib/ui-auto-monkey/custom.js:

monkey.config.eventWeights = {
            tap: 100,
            drag: 10,
            flick: 10,
            orientation: 1,
            lock: 1,
            pinchClose: 1,
            pinchOpen: 1,
            shake: 1
        };

lock: 1, 改成 lock: 0,

4、真机上务必开启 Enable UI Automation(在 Settings->Developer 里面),否则真机上运行时应用会闪退。

这个其实不算坑,是使用不当,而且官方 TroubleShooting 有提到,所以没有报 issue。而且出错后把测试报告的 sys log 看完就知道是啥问题了。

解决方法: 在真机上开启 Enable UI Automation。

5、真机上运行会出现如下错误:

INSTRUMENTS_TRACE_PATH : /Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/*.trace
RESULT_BASE_PATH : /Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/smart_monkey_result
{:app_path=>"chj.ToDoList", :device=>"2143b478b4141119f7ae286abdb693ebefd01ea5", :run_count=>1, :time_limit_sec=>nil, :detail_event_count=>50}
=================================== Start Test (1/1) =======================================
2015-06-07 23:59:36.852 instruments[7896:266326] WebKit Threading Violation - initial use of WebKit from a secondary thread.
Attempting iOS device system log capture via deviceconsole.
BundleID was found: chj.ToDoList
Run: ["instruments", "-w", "2143b478b4141119f7ae286abdb693ebefd01ea5", "-t", "/Applications/Xcode.app/Contents/Applications/Instruments.app/Contents/PlugIns/AutomationInstrument.xrplugin/Contents/Resources/Automation.tracetemplate", "chj.ToDoList", "-e", "UIASCRIPT", "/Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/smart_monkey_result/report_20150607235935/custom.js", "-e", "UIARESULTSPATH", "/Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/smart_monkey_result/report_20150607235935"]
2015-06-07 23:59:42.646 instruments[7908:266524] WebKit Threading Violation - initial use of WebKit from a secondary thread.
2015-06-07 15:59:50 +0000 Default: {"width":1024.0000335703464,"height":768.0000447604625}
...
2015-06-07 15:59:52 +0000 Debug: target.tapWithOptions({x:"332.7966623166576", y:"-42.84549929387867"}, {touchCount:"1", tapCount:"1", duration:"0"})
2015-06-07 15:59:52 +0000 Debug: tap point is not within the bounds of the screen
2015-06-07 15:59:52 +0000 Debug: MonkeyTest finish.
2015-06-07 15:59:52 +0000 Error: Script threw an uncaught JavaScript error: tap point is not within the bounds of the screen on line 145 of UIAutoMonkey.js
2015-06-07 15:59:53 +0000 Stopped: Script was stopped by the user
2015-06-07 23:59:53.194 instruments[7908:266624] Attempting to set event horizon when core is not engaged, request ignored
Instruments Trace Complete (Duration : 10.302137s; Output : /Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/instrumentscli0.trace)
Stop iOS system log capture.
2015-06-07 23:59:57.887 instruments[7918:266679] WebKit Threading Violation - initial use of WebKit from a secondary thread.
2015-06-08 00:00:05.761 instruments[7937:267018] WebKit Threading Violation - initial use of WebKit from a secondary thread.
2015-06-08 00:00:07.390 instruments[7947:267059] WebKit Threading Violation - initial use of WebKit from a secondary thread.
Monkey Test Report:/Users/hengjiechen/Develop/iOS/research/CrashMonkey4IOS/bin/smart_monkey_result/report_20150607235935/index.html
EXIT 0

我的真机是 iPad mini2 + iOS 8.2 (买不起 iPhone 。。。),目前只能估计是 CrashMonkey4iOS 对 iPad 的支持还不是太好。

解决方法:暂时无解。

总结

虽然存在一些问题,但这是目前能找到的最好的 Monkey 测试完整解决方案,它让 发现问题和解决问题的效率同时得到了提高。

感谢 @vigossjjj 为我们带来这么好的工具。


↙↙↙阅读原文可查看相关链接,并与作者交流