背景

Ui 自动化测试框架

目标

  1. 确保质量
  2. 减少人工成本
  3. 统一标准

考虑方面

  1. 平台支持。
  2. 稳定性。
  3. 维护成本。
  4. 可扩展性。

All: Appium, kobiton, calabash, MonkeyTalk
Andoird: robotinum, uiautomator, espresso
iOS: XCTest, UIAutomation, Frank, KIF, Kiwi
Appium + cucumber .使用 cucumber BDD 框架,底层还是使用 appium 。为封装更多 step,可以使用 selenium-cucumber(待查看)。

支付宝开源了 SoloPi android 自动化测试工具。是依靠录制回放、性能测试、一机多控 来实现的。测试是通过录制用户操作,记录下来再在各个设备上回放。

行为驱动开发(Behavior-driven development,缩写BDD)是一种敏捷软件开发的技术。它通过用自然语言书写非程序员可读的测试用例扩展了测试驱动开发方法。
满足 BDD 和 跨平台是我们需要的。

Calabash 满足 BDD 和 跨平台。 相对小众,且 ios 接入 calsbash 成本太高。Calabash 对元素的定位主要依赖 ID,ios 一般不具备这个条件。

Appium 是一个C/S架构,核心是一个Web服务器,它提供了一套REST的接口。当收到客户端的连接后,就会监听到命令,然后在移动设备上执行这些命令,最后将执行结果放在HTTP响应中返还给客户端。
可以和 python 很好结合,但是在业务快速发展的过程中,维护成本会越来越难以接受

结合 Calabash 和 Appium,通过 appium + cucumber + selenium 来实现上层使用 cucumber 来满足 BDD,底层使用 appium 来确保跨平台和稳定性

搭建初始化

现在跑通的是 appium + cucumber + selenium + ruby

  1. ruby 安装
    mac 自带的 2.3.0 可能不够用,需要安装 2.4.0 以上的。
    可以直接 brew install ruby 安装,注意要设置环境变量取代默认版本.
  2. Cucumber.rb
    gem install cucumber
  3. s

开始

新建文件夹

mkdir TestDemo

初始化 cucumber。

cucumber –init
执行上面命令,会生成如下目录结构:

1
2
3
4
features # 存放feature的目录
├── step_definitions # 存放steps的目录
└── support # 环境配置
└── env.rb

创建 Gemfile 文件

创建 Gemfile 文件
touch Gemfile

打开Gemfile,导入Ruby库

1
2
3
4
5
6
7
8
9
10
11
source 'https://www.rubygems.org'

gem 'appium_lib', '~> 9.7.4'
gem 'rest-client', '~> 2.0.2'
gem 'rspec', '~> 3.6.0'
gem 'cucumber', '~> 2.4.0'
gem 'rspec-expectations', '~> 3.6.0'
gem 'spec', '~> 5.3.4'
gem 'sauce_whisk', '~> 0.0.13'
gem 'test-unit', '~> 2.5.5'
gem 'selenium-cucumber', '~> 3.1.5'

安装依赖库

1
2
3
4
5
# 需要先安装bundle
gem install bundle

# 安装ruby依赖
bundle install

配置运行基本信息

  1. 进入features/support目录,新建appium.txt文件
  2. 编辑appium.txt文件,这里只配置了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
26
27
28
29
30
31
32
[caps]
# 模拟器
#platformName = "ios"
#deviceName = "iPhone X"
#platformVersion = "11.2"
#app = "./apps/AutoUITestDemo.app"
#automationName = "XCUITest"
#noReset="true"

# 真机
# platformName = "ios"
# deviceName = "xxx"
# platformVersion = "10.3.3"
# app = "./apps/AutoUITestDemo.app"
# automationName = "XCUITest"
# udid = "xxxx"
# xcodeOrgId = "QT6N53BFV6"
# xcodeSigningId = "ZHH59G3WE3"
# autoAcceptAlerts = "true"
# waitForAppScript = "$.delay(5000); $.acceptAlert();" # 处理系统弹窗

# android
platformName = "android"
deviceName = "ce10171a72ba8938057e"
platformVersion = "8"
app = "./app/app-flavors_process_test-debug.apk"//app 路径,这是以 TestDemo 文件夹为相对路径
automationName = "UIAutomator2"
noSign="true"

[appium_lib]
sauce_username = false
sauce_access_key = false
  1. 打开env.rb文件,配置启动入口
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

require 'rspec/expectations'
require 'appium_lib'
require 'selenium-cucumber'
# require 'cucumber/ast'

# Create a custom World class so we don't pollute `Object` with Appium methods
class AppiumWorld
end

if ENV['IDEVICENAME']=='android'
caps = Appium.load_appium_txt file: File.expand_path("./../android/appium.txt", __FILE__), verbose: true
elsif ENV['IDEVICENAME']=='ios'
caps = Appium.load_appium_txt file: File.expand_path("./../ios/appium.txt", __FILE__), verbose: true
else
caps = Appium.load_appium_txt file: File.expand_path('./', __FILE__), verbose: true
end

# end
Appium::Driver.new(caps, true)
Appium.promote_appium_methods AppiumWorld

World do
AppiumWorld.new
end

Before { $driver.start_driver }
After { $driver.driver_quit }

features目录下,新建guide.feature文件,用来描述测试用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@guidepage
Feature: 引导页
1.首次安装应用,判断是否展示引导页;
滑到最后一张,判断是否展示“登录/注册”和“进入首页”两个按钮;
点击“登录/注册”按钮,判断是否展示登录界面。
2.滑动到最后一张引导页,点击“进入首页”按钮,判断引导页是否还存在。

@guide_01
Scenario: 首次安装应用,展示引导页;滑动到最后一张引导页,展示“登录/注册”和“进入首页”两个按钮
When 展示引导页
Then 滑动到最后一页
Then 展示“登录/注册”和“进入首页”两个按钮
When I press "“登录/注册"
Then I see "登陆"

@guide_02
Scenario: 点击最后一张引导页“进入首页”按钮,判断引导页是否还存在
When 滑动到最后一张引导页,点击“进入首页”按钮
Then 退出引导页

在step_definitions目录下,新建 common_steps.rb 和 guide.rb 文件,用来存放脚本代码

  • 编写rb脚本之前,先打开 appium,再执行 cucumber 命令。
  • 将 cucumber 提示我们实现的复制下来放到 rb 文件
  • common_steps.rb 可以放公共的可复用的 step,guide.rb 放自己的step
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
common_steps.rb 

When("I press {string}") do |string|
guideIsExist = exists { button(string) }
puts guideIsExist ? "存在该按钮" : "不存在该按钮"
expect(guideIsExist).to be true
sleep 1
end

guide.rb

# @guide_01
# 首次安装应用,判断是否展示引导页;
# 滑到最后一张,判断是否展示“登录/注册”和“进入首页”两个按钮;
# 点击“登录/注册”按钮,判断是否展示登录界面。
When("展示引导页") do
guideIsExist = exists { button("现在就下载") }
puts guideIsExist ? "存在引导页面" : "不存在引导页面"
expect(guideIsExist).to be true
end

Then(/^滑动到最后一页$/) do
swipe_to_last_guide_view
sleep(1)
end

Then(/^展示“登录\/注册”和“进入首页”两个按钮$/) do
$loginBtnIsExist = exists { button("现在就下载") }
puts $loginBtnIsExist ? "存在“登录/注册”按钮" : "不存在“登录/注册”按钮"
expect($loginBtnIsExist).to be true

startBtnIsExist = exists { id("setting_items") }
puts startBtnIsExist ? "存在“进入首页”按钮" : "不存在“进入首页”按钮"
expect(startBtnIsExist).to be true
end
  • 运行 cucumber –dry-run 来查看 rb 文件编写测试实例的情况(是否有遗漏)
  • 运行 cucumber。cucumber –tags @guidepage 可按 tags 运行。

补充

元素定位

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
# 1、使用button查找按钮
first_button // 查找第一个button
button(value) // 查找第一个包含value的button,返回[UIAButton|XCUIElementTypeButton]对象
buttons(value) // 查找所有包含value的所有buttons,返回[Array<UIAButton|XCUIElementTypeButton>]对象

eg:
button("登录") // 查找登录按钮

# 2、使用textfield查找输入框
first_textfield // 查找第一个textfield
textfield(value) // 查找第一个包含value的textfield,返回[TextField]

eg:
textfield("用户名") // 查找

# 3、使用accessibility_id查找
id(value) // 返回id等于value的元素

eg:
id("登录") // 返回登录按钮
id("登录页面") // 返回登录页面

# 4、通过find查找
find(value) // 返回包含value的元素
find_elements(:class, 'XCUIElementTypeCell') // 通过类名查找

eg:
find("登录页面")

# 5、通过xpath查找
xpath(xpath_str)

# web元素定位:
# 测试web页面首先需要切换driver的上下文
web = driver.available_contexts[1]
driver.set_context(web)

# 定位web页面的元素
driver.find_elements(:css, ".re-bb") # 通过类选择器.re-bb定位css的元素

更多详情

常用事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 通过坐标点击
tap(x: 68, y: 171)

// 通过按钮元素点击
button("登录").click

// 滑动手势
swipe(direction:, element: nil) // direction - Either 'up', 'down', 'left' or 'right'.

eg: 上滑手势
swipe(direction: "up", element: nil)

// wait
wait { find("登录页面") } // 等待登录页面加载完成

// sleep
sleep(2) // 延时2秒

更多详情

断言

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
# 1. 相等
expect(actual).to eq(expected) # passes if actual == expected
expect(actual).to eql(expected) # passes if actual.eql?(expected)
expect(actual).not_to eql(not_expected) # passes if not(actual.eql?(expected))

# 2、比较
expect(actual).to be > expected
expect(actual).to be >= expected
expect(actual).to be <= expected
expect(actual).to be < expected
expect(actual).to be_within(delta).of(expected)

# 3、类型判断
expect(actual).to be > expected
expect(actual).to be >= expected
expect(actual).to be <= expected
expect(actual).to be < expected
expect(actual).to be_within(delta).of(expected)

# 4、Bool值比较
expect(actual).to be_truthy # passes if actual is truthy (not nil or false)
expect(actual).to be true # passes if actual == true
expect(actual).to be_falsy # passes if actual is falsy (nil or false)
expect(actual).to be false # passes if actual == false
expect(actual).to be_nil # passes if actual is nil
expect(actual).to_not be_nil # passes if actual is not nil

# 5、错误
expect { ... }.to raise_error
expect { ... }.to raise_error(ErrorClass)
expect { ... }.to raise_error("message")
expect { ... }.to raise_error(ErrorClass, "message")

# 6、异常
expect { ... }.to throw_symbol
expect { ... }.to throw_symbol(:symbol)
expect { ... }.to throw_symbol(:symbol, 'value')

更多断言详情

引言

最近微信开源了 mmkv,之前曾经深为 android 跨进程数据共享和通信所困惑,用 contextprovider 里面 sharedpreference,也曾经考虑过用文件读写来实现,可是 Java 端对文件读写跨进程操作实在是没有很大可操作余地,ndk写的话又太耗时而且无法保障测试性能等等问题。现在开源的 mmkv 正好弥补来这一块空缺,而且结果微信检验,在性能和安全方面感觉还是比较靠谱的。

详解

跨进程数据共享主要有以下问题:

  1. 多进程数据如何保持数据一致性即写更新,读的都是最新的
  2. 如何保证稳定性和高效性,降低性能消耗

mmkv 最初的设计并不是为了考虑多进程情况。主要是提高了 key-value 存储的性能。

  1. 使用 protobuf 二进制来存储数据。作为高效数据压缩编码方式,无疑提高了写入和读取性能
  2. 增量更新。通过将修改数据写在后面,等待内存满了之后触发重整进行整理。提高了修改操作的性能,不需要再去查询旧数据进行修改。当然在不断触发内存重整的情况下会大大损耗性能(回到),但一般情况下这明显是低概率事件.且存储限制会指数增长。
  3. mmap 文件映射内存,省去一次拷贝的时机。

而之后考虑 android 多进程的情况,针对多进程需要考虑情况:

  1. 指示器。拿文件前面几个字节作为当前写的位置。多进程模式下,每个进程读写时候都要检查一下当前和内存是否一致。不一致则需要读取新写的。
  2. 锁。使用了文件读写锁,在外部做了封装,可以更好支持。
  3. 增加了 Ashmem 的支持。

使用

  1. 使用简单,最好直接使用 static 的依赖,因为普通的依赖会添加 libc++_shared.so ,会导致包比 static 大2倍以上
    1
    implementation 'com.tencent:mmkv-static:1.0.19'
  2. 性能测试,多进程和单进程性能相差很小。1000 次写稳定在几十毫秒,在新机器上会达到20、30毫秒内。1000 次读能稳定在10毫秒左右。偶尔可能会有波动。总体看性能比读写 file 高10倍以上,比 sharedference 写高百倍(因为 sharedference 就算使用 apply,在最后未完成也要补回来),读因为 sharedference 是内存操作所以相差不大。
  3. 因为 mmkv 在 native 层做了较多缓存,所以在使用是可以不需要考虑创建性能单例等等问题

注意事项

  1. 使用 file 没有特别注意的地方,但是要注意自己不要每次都添加很大的数据,很频繁触发内存重整,效率会很低。

  2. 使用 Ashmem 的话,有很多注意地方。可以的话能不用就不用,使用 file + 逻辑来代替

    1. 内部实现实际使用了 MMKVContentProvider 来传递文件描述符
    2. 在 X86 某些机型上很容易 anr
    3. 如果 MMKVContentProvider 所在进程挂了重新启动,会导致 ashmem 生成新的,和其它还存在进程不一致。
  3. 因为 mmkv 无法保障原子性操作。类似乐观锁的需要自己实现

  4. mmkv 采用 mmap,实际上 binder 内部实现也是使用 mmap。所以不需要过多担心内部稳定性

背景

MVVM 是现在 google 推荐的 android 架构方式,而且还推出了 jetpack 套件。基于此套件可以很简单开发一个 MVVM app。

问题

livedata 是 google 推出的观察者模式,因为它和 app 的组件(activity、fragment、service)生命周期绑定,所以会确保在生命周期内才会更新数据。

因为 room 组件支持返回 livedata,但是按照模块顺序是 view -> viewmodel -> repository -> datasource(room、network)。

如果现在需求是在 view 要展示任务列表数据并且随着数据变化实时更新。那么 repository 层返回 livedata,viewmodel 层也定义了 livedata(给 view 展示用),那么难道要在 viewmodel 里在做 livedata 的 observer 吗?尤其是在用了 databinding 的时候

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
private MutableLiveData<List<Task>> items = new MutableLiveData<>();
public void loadTasks(boolean forceUpdate) {
tasksRepository.getTasks(forceUpdate, new LoadTasksCallback() {
@Override
public void onTasksLoaded(@NonNull LiveData<List<Task>> tasks) {
tasks.observe(lifecycleOwner, new Observer<List<Task>>() {
@Override
public void onChanged(List<Task> tasks) {
showTask(tasks);
}
});
List<Task> taskList = tasks.getValue();
if (taskList != null && !taskList.isEmpty()) {
showTask(taskList);
}
}

@Override
public void onDataNotAvailable() {
isDataLoadingError.setValue(true);
items.setValue(new ArrayList<Task>());
}
});
}

但是这个问题有点多:
1. 每次调用 loadTasks 都要重新拿一次,既然已经监听了,那何必在拿
2. viewmodel 持有 lifecycleOwner。
3. showtask 多线程调用
4. 返回值没有明确定义。接口不清晰

我们希望的是一开始调接口展示数据,并且监听变化。既然监听了变化,那是否还有必要对外提供刷新接口了。有必要,因为强制刷新是会去服务器查询,监听变化其实主要是针对本地的,远端的可能是隔一段时间查一次。
对 viewmodel 来说它不需要关心是从哪里来怎么来的。

网络库重点:

  • 稳定
  • 高效,可以复用连接
  • 有缓存,不用重复请求

okhttp

毋庸置疑,使用 okhttp 做为网络框架。

  • okhttp 已经稳定使用很长时间,应用很广泛。

  • okhttp 多路复用,有连接池。初步来看时根据 host 等等进行复用。连接池默认是5个,5分钟

    1. 提供了 okio 进行读写。Buffer 使用双向链表 segment(segment 还有 compact 和 flit 优化,以及 segmentpool 来进行维护) 来进行存储数据,输入输出经过 buffer 达到低的CPU和内存消耗。
    2. okhttp 是依靠先判断 host 在判断是否归为一个 route ,且支持 http2 多路复用。
  • okhttp 自己有缓存策略,可以 new Cache 指定缓存目录和大小,内部会使用 LRU 策略清理。也可以自己 Request 设置 CasheControl ,推荐有 FORCE_NETWORK 和FORCE_CACHE,不符合要求可以自己设置builder。

    1. 服务器支持缓存,即服务器在 response 头部有 Cache-Control, okhttp 会处理对应情况。
    2. 服务器不支持缓存,想要支持就要在 NetworkInterceptor 里面处理 response,remove Pragma ,添加 Cache-Control。

retrofit

对 okhttp 进行了封装。

Retrofit 是一个 restful 的 HTTP 网络请求框架的封装。
网络请求的工作本质上是 OkHttp 完成,而 Retrofit 仅负责 网络请求接口的封装
App应用程序通过 Retrofit 请求网络,实际上是使用 Retrofit 接口层封装请求参数、Header、Url 等信息,之后由 OkHttp 完成后续的请求操作
在服务端返回数据之后,OkHttp 将原始的结果交给 Retrofit,Retrofit根据用户的需求对结果进行解析
相对其他开源库而言代码简洁使用更加方便.

感想

阅读这2个库发现一些很有意思的事情

  • 因为 Cache 类和 CacheInterceptor 不在一个目录下,CacheInterceptor 需要持有 Cache 实例。一般做法是 Cache public ,对应方法也要 public。但是 okhttp 并没有这么做。它定义了一个接口 InternalCache。Cache 类内部 持有实现了 InternalCache 实例。CacheInterceptor 类再由 okhttp client 构造里面传递进来 cache 里的 InternalCache示例。从而实现了 Cache 类方法不 public 另外目录也可以调用,且调用由接口来约束。在4.X版本 okhttp 用 kotlin 实现,因为 kotlin 可以控制包内访问,所以删除了 InternalCache 接口类。
    这种场景平时其实平时很容易碰到,为了项目易维护,要分目录来进行划分,但是 java 因为语言特性,一旦外界想要访问必须要 public 。结果导致很多不想 public 的方法因此暴露或者导致无法放在不同目录下。okhttp 这种思路虽然略显 繁琐,但符合开发原则。值得推荐学习。
  • 定义了一个 Internal 抽象类,内部持有自身实例 public static Internal instance,由 OkHttpClient 主对外类来实现并赋值。从而来打通 internal 目录下和外界的调用。okhttp 做的很好的点在于将面向对象特性发挥的很好。类职责较单一,但类实例传递很频繁。往往一个类层层传递到深处仍在正常使用。这对类实例稳定性其实由很高的要求。
  • retrofit 不涉及具体网络请求,对请求和返回值等接口做了封装。要是我们自己简单使用的话,实际上不需要retrofit,统一封装一个 request、response 就可以。但是从程序设计的角度,考虑以后扩展的话,就不得不考虑 request 多样性、reponse 多样性和是否可以转换,具体请求是否可以方便替换等等。retrofit 做到了这些,同时还采用了注解的方式,来简化了很多代码,从易用性和可扩展性上实现了平衡,请求用注解,后续很多都采用 adapter,factory 来实现可扩展。从测试方面,retrofit 对外提供了 mock 库,可以自己定义 response 返回,当然,因为它是基于 okhttp 的,用拦截器也能实现。对内单元测试覆盖较全,使用 mockwebserver 模拟请求。

引言

前几天听同事讲有个 idleHandler 可以不和主线程其它工作争抢,可以做到空闲时候运行。当时想着这一块好像有点印象,记得在看 handler 源码时候看到 message 获取后面还有一段有关 idle 的代码,但是没有细看。这么一想,看源码还是不够仔细啊。虽然感觉这个实际上应该是在 message 为空的时候然后执行任务,思路挺简单明了的。但是今天正好有空,就好好看了一下源码,顺便查漏补缺。

详解

很轻松的找到 MessageQueue.IdleHandler,里面就一个接口方法 queueIdle 需要实现。看注释是返回 true 则保持,返回 false 则执行完删除。在 next 方法里可以看到当前没有要执行的消息或者还没到执行时间会往下查看有没有 idle 想要运行,有则运行,没有则返回。但里面仍然不能做耗时操作。这种适用场景挺少的。

建议慎用只在有些界面加载不急的时候可以放在这里。因为有的可能认为不怎么耗时,但是一用的多了就比较容易出 anr,而且还很难查,我这块不是耗时操作啊,为什么会 anr,可能就是因为大家都怎么想,结果可想而知,anr 会越来越多,也越来越难查,千万不要开这个头。其实既然加载或者初始化操作可以延迟,那为什么不直接放在子线程而要放在主线程。并且 android 系统内部使用也很少,主要是 gc 那块使用了,doGcIfNeeded()。

结论

所以 idleHandler 不是天堂,被滥用会导致很麻烦的 anr 问题,谨慎使用。

ps:感觉 android 有很多坑在,比如 Handler,本以为消耗很低啊,这么好用,等待过程还会被挂起,简直是神器。但是在深入下去会发现,其实 它底层用管道,通过监听来实现。一个 Handler 至少持有 4 个fd。虽然采用 epoll_wait 尽可能高效,但是以后也不敢在随意使用。毕竟看到 fd 超过限制的错误还是不少的。

开始

希望可以坚持最少一周一次频率进行更新。可能有多有少,坚持就是胜利。

0%