什么是ByteBuf?

ByteBuf在Netty中充当着非常重要的角色;它是在数据传输中负责装载字节数据的一个容器;其内部结构和数组类似,初始化默认长度为256,默认最大长度为Integer.MAX_VALUE。

ByteBuf数据结构

* <pre>
* +-------------------+------------------+------------------+
* | discardable bytes | readable bytes | writable bytes |
* | | (CONTENT) | |
* +-------------------+------------------+------------------+
* | | | |
* 0 <= readerIndex <= writerIndex <= capacity
* </pre>

ByteBuf字节缓冲区主要由discardablereadablewritable三种类型的字节组成的;

ByteBuf字节缓冲区可以操控readerIndexwriterIndex二个下标;这两个下标都是单独维护

名词 解释 方法
discardable bytes 丢弃的字节;ByteBuf中已经读取的字节 discardReadBytes();
readable bytes 剩余的可读的字节
writable bytes 已经写入的字节
readerIndex 字节读指针(数组下标) readerIndex()
writerIndex 字节写指针(数组下标) writerIndex()

ByteBuf中主要的类-UML图

ByteBuf怎么创建的?

ByteBuf是通过Unpooled来进行创建;默认长度为256,可自定义指定长度,最大长度为Integer.MAX_VALUE;

ByteBuf创建的类型有哪几种?

1. 基于内存管理分类

类型 解释 对应的字节缓冲区类
Pooled 池化;
简单的理解就是pooled拥有一个pool池空间(poolArea),
凡是创建过的字节缓冲区都会被缓存进去,
有新的连接需要字节缓冲区会先从缓存中get,
取不到则在进行创建;
1.PooledDirectByteBuf
2.PooledHeapByteBuf
3.PooledUnsafeDirectByteBuf
4.PooledUnsafeHeapByteBuf
Unpooled 非池化;
每次都会创建一个字节缓冲区
1.UnpooledDirectByteBuf
2.UnpooledHeapByteBuf
3.UnpooledUnsafeDirectByteBuf
4.UnpooledUnsafeHeapByteBuf

优缺点:

  • 在频繁的创建申请字节缓冲区的情况下,池化要比非池化要好很多,池化减少了内存的创建和销毁,重复使用
  • 在非频繁的情况下,非池化的性能要高于池化,不需要管理维护对象池,所以在不需要大量使用ByteBuf的情况下推荐使用非池化来创建字节缓冲区

2. 基于内存分类

类型 解释 特点 构造方法
heapBuffer(常用) 堆字节缓冲区; 底层就是JVM的堆内存,只是IO读写需要从堆内存拷贝到内核中(类似之前学过的IO多路复用) buffer(128)
directBuffer(常用) 直接内存字节缓冲区; 直接存于操作系统内核空间(堆外内存) directBuffer(256)

优缺点:

  • heapBuffer是在JVM的堆内存中分配一个空间,使用完毕后通过JVM回收机制进行回收,但是数据传输到Channel中需要从堆内存中拷贝到系统内核中
  • directBuffer直接在堆外,系统内核中开辟一个空间,在数据传输上要比heapBuffer高(减少了内存拷贝),但是由于不受JVM管理在创建和回收上要比heapBuffer更加耗时耗能;

每一种都有自己优势的地方,我们要根据实际的业务来灵活的运用;如果涉及到大量的文件操作建议使用directBuffer(搬来搬去确实挺耗性能);大部分业务还是推荐使用heapBuffer(heapBuffer,普通的业务搬来搬去相比在内核申请一块内存和释放内存来说要更加优)。

ByteBuf是怎么样回收的

1. heapBuffer

heapBuffer是基于堆内存来进行创建的,回收自然而然通过JVM的回收机制进行回收

2. directBuffer回收内存的方法

可以通过DirectByteBuffer中的Cleaner来进行清除
或者依靠unsafe的释放内存(freeMemory方法)也可以进行回收

源码分析

ByteBuf内存分配

ByteBuf的内存分配主要分为heap(堆内存)和direct(堆外内存);

1. heap堆内存的分配:通过UnpooledByteBufAllocator类进行内存分配

1.1 创建堆缓冲区

protected ByteBuf newHeapBuffer(int initialCapacity, int maxCapacity) {
// 是否支持unsafe
return PlatformDependent.hasUnsafe() ?
// 创建unsafe非池化堆字节缓冲区
new InstrumentedUnpooledUnsafeHeapByteBuf(this, initialCapacity, maxCapacity) :
// 创建非池化堆字节缓冲区
new InstrumentedUnpooledHeapByteBuf(this, initialCapacity, maxCapacity);
}

1.2 unsafe和非unsafe都是通过实例 UnpooledHeapByteBuf 来分配内存

// InstrumentedUnpooledUnsafeHeapByteBuf/InstrumentedUnpooledHeapByteBuf
// 最终都是通过这个方法来创建分配内存;后面会讲讲unsafe和普通的非unsafe的区别
protected UnpooledHeapByteBuf(ByteBufAllocator alloc, int initialCapacity, int maxCapacity) {
// 设置最大容量
super(maxCapacity);
// 检查内存分配类是否为空
checkNotNull(alloc, "alloc"); if (initialCapacity > maxCapacity) {
throw new IllegalArgumentException(String.format(
"initialCapacity(%d) > maxCapacity(%d)", initialCapacity, maxCapacity));
} this.alloc = alloc;
// allocateArray初始化一个initialCapacity长度的字节数组
setArray(allocateArray(initialCapacity));
// 初始化读写索引为0
setIndex(0, 0);
} // 初始化一个initialCapacity长度的字节数组
byte[] allocateArray(int initialCapacity) {
return new byte[initialCapacity];
} // 初始化读写索引为0
@Override
public ByteBuf setIndex(int readerIndex, int writerIndex) {
if (readerIndex < 0 || readerIndex > writerIndex || writerIndex > capacity()) {
throw new IndexOutOfBoundsException(String.format(
"readerIndex: %d, writerIndex: %d (expected: 0 <= readerIndex <= writerIndex <= capacity(%d))",
readerIndex, writerIndex, capacity()));
}
setIndex0(readerIndex, writerIndex);
return this;
} final void setIndex0(int readerIndex, int writerIndex) {
this.readerIndex = readerIndex;
this.writerIndex = writerIndex;
}

从源码可以得知,堆内存ByteBuf通过判断系统环境是否支持unsafe来判断是创建UnsafeHeapByteBuf还是heapByteBuf; 如果支持unsafe则返回 InstrumentedUnpooledUnsafeHeapByteBuf 实例,反之则返回 InstrumentedUnpooledHeapByteBuf实例;但它们都是分配一个byte数组来进行存储字节数据。

1.3 unsafe和非unsafe创建的ByteBuf有什么区别呢

unsafe和非unsafe创建的heapByteBuf区别在于获取数据;非unsafe获取数据直接是通过数组索引来进行获取的;而unsafe获取数据则是通过UNSAFE操控内存来获取;我们可以通过源码来看看

heapByteBuf获取数据

@Override
public byte getByte(int index) {
ensureAccessible();
return _getByte(index);
} @Override
protected byte _getByte(int index) {
return HeapByteBufUtil.getByte(array, index);
} // 直接返回数组对应索引的值
static byte getByte(byte[] memory, int index) {
return memory[index];
}

unsafeHeapByteBuf获取数据

@Override
public byte getByte(int index) {
checkIndex(index);
return _getByte(index);
} @Override
protected byte _getByte(int index) {
return UnsafeByteBufUtil.getByte(array, index);
} static byte getByte(byte[] array, int index) {
return PlatformDependent.getByte(array, index);
} public static byte getByte(byte[] data, int index) {
return PlatformDependent0.getByte(data, index);
} static byte getByte(byte[] data, int index) {
// 通过unsafe来获取
return UNSAFE.getByte(data, BYTE_ARRAY_BASE_OFFSET + index);
}

2. direct内存分配:unsafe创建和非unsafe创建

2.1 创建directBuffer

// PlatformDependent检测运行环境的变量属性,比如java环境,unsafe是否支持等
@Override
protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {
final ByteBuf buf;
// 支持unsafe
if (PlatformDependent.hasUnsafe()) {
// 运行环境是否使用不清空的direct内存
buf = PlatformDependent.useDirectBufferNoCleaner() ?
new InstrumentedUnpooledUnsafeNoCleanerDirectByteBuf(this, initialCapacity, maxCapacity) :
new InstrumentedUnpooledUnsafeDirectByteBuf(this, initialCapacity, maxCapacity);
} else {
// 创建非unsafe实例ByteBuf
buf = new InstrumentedUnpooledDirectByteBuf(this, initialCapacity, maxCapacity);
}
return disableLeakDetector ? buf : toLeakAwareBuffer(buf);
}

2.2 unsafe返回 UnpooledUnsafeDirectByteBuf 实例,非unsafe返回 UnpooledDirectByteBuf 实例

// 创建unsafe direct字节缓冲区
protected UnpooledUnsafeDirectByteBuf(ByteBufAllocator alloc, int initialCapacity, int maxCapacity) { // 设置最大容量
super(maxCapacity);
if (alloc == null) {
throw new NullPointerException("alloc");
}
if (initialCapacity < 0) {
throw new IllegalArgumentException("initialCapacity: " + initialCapacity);
}
if (maxCapacity < 0) {
throw new IllegalArgumentException("maxCapacity: " + maxCapacity);
}
if (initialCapacity > maxCapacity) {
throw new IllegalArgumentException(String.format(
"initialCapacity(%d) > maxCapacity(%d)", initialCapacity, maxCapacity));
} this.alloc = alloc;
// allocateDirect创建DirectByteBuffer(java nio)分配内存
setByteBuffer(allocateDirect(initialCapacity), false);
} // 非unsafe创建direct内存
protected UnpooledDirectByteBuf(ByteBufAllocator alloc, int initialCapacity, int maxCapacity) {
super(maxCapacity);
if (alloc == null) {
throw new NullPointerException("alloc");
}
if (initialCapacity < 0) {
throw new IllegalArgumentException("initialCapacity: " + initialCapacity);
}
if (maxCapacity < 0) {
throw new IllegalArgumentException("maxCapacity: " + maxCapacity);
}
if (initialCapacity > maxCapacity) {
throw new IllegalArgumentException(String.format(
"initialCapacity(%d) > maxCapacity(%d)", initialCapacity, maxCapacity));
} this.alloc = alloc;
setByteBuffer(ByteBuffer.allocateDirect(initialCapacity));
}

2.3 分配 direct内存,返回 java nio ByteBuffer实例

protected ByteBuffer allocateDirect(int initialCapacity) {
return ByteBuffer.allocateDirect(initialCapacity);
} /**
* Allocates a new direct byte buffer. 分配一个新的直接内存字节缓冲区
*
* <p> The new buffer's position will be zero, its limit will be its
* capacity, its mark will be undefined, and each of its elements will be
* initialized to zero. Whether or not it has a
* {@link #hasArray backing array} is unspecified.
*
* @param capacity
* The new buffer's capacity, in bytes
*
* @return The new byte buffer
*
* @throws IllegalArgumentException
* If the <tt>capacity</tt> is a negative integer
*/
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
} // allocateDirect创建DirectByteBuffer(java nio)
DirectByteBuffer(int cap) { // package-private
// 设置文件描述, 位置等信息
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap); long base = 0;
try {
// 通过unsafe类来分配内存
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
// 实例化cleaner,用于后续回收
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}

2.4 设置ByteBuffer的属性(unsafe和非unsafe)

unsafe设置ByteBuffer

/**
* buffer 数据字节
* tryFree 尝试释放,默认为false
*/
final void setByteBuffer(ByteBuffer buffer, boolean tryFree) {
if (tryFree) {
// 全局buffer设置成旧buffer
ByteBuffer oldBuffer = this.buffer;
if (oldBuffer != null) {
if (doNotFree) {
doNotFree = false;
} else {
// 释放旧缓冲区的内存
freeDirect(oldBuffer);
}
}
}
// 将当前传入的buffer设置成全局buffer
this.buffer = buffer;
// 记录内存地址
memoryAddress = PlatformDependent.directBufferAddress(buffer);
// 将临时buff设置为null
tmpNioBuf = null;
// 记录容量大小
capacity = buffer.remaining();
} // 获取对象在内存中的地址
static long directBufferAddress(ByteBuffer buffer) {
return getLong(buffer, ADDRESS_FIELD_OFFSET);
} // 通过unsafe操控系统内存,获取对象在内存中的地址
private static long getLong(Object object, long fieldOffset) {
return UNSAFE.getLong(object, fieldOffset);
}

非unsafe设置ByteBuffer

// 非unsafe设置属性
private void setByteBuffer(ByteBuffer buffer) {
ByteBuffer oldBuffer = this.buffer;
if (oldBuffer != null) {
if (doNotFree) {
doNotFree = false;
} else {
freeDirect(oldBuffer);
}
} this.buffer = buffer;
tmpNioBuf = null;
capacity = buffer.remaining();
}

根据源码,direct通过判断运行系统环境是否使用useDirectBufferNoCleaner来实例不同的ByteBufferedReader(unsafe和非unsafe),但是他们最终都是通过ByteBuffer来分配内存,底层都是通过在不同的ByteBuf实例中构建一个ByteBuffer来进行存储字节数据的(具体可以看看UnpooledDirectByteBuf的set方法)

2.5 unsafe和非unsafe创建的directByteBuf的区别

unsafe获取数据:UNSAFE通过索引的内存地址来获取对应的值

@Override
protected byte _getByte(int index) {
// UnsafeByteBufUtil unsafe工具类获取
return UnsafeByteBufUtil.getByte(addr(index));
} // addr 获取索引的内存地址
long addr(int index) {
return memoryAddress + index;
} static byte getByte(long address) {
return PlatformDependent.getByte(address);
} public static byte getByte(long address) {
return PlatformDependent0.getByte(address);
} // 通过UNSAFE获取内存地址的值
static byte getByte(long address) {
return UNSAFE.getByte(address);
}

非unsafe获取数据: 直接通过对应索引的ByteBuffer获取值

@Override
public byte getByte(int index) {
// 检查授权,及ByteBuffer对象是否还有引用
ensureAccessible();
return _getByte(index);
} @Override
protected byte _getByte(int index) {
// 通过索引获取值
return buffer.get(index);
}

3. ByteBuf扩容

每次我们再往字节缓冲区中写入数据的时候都会判断当前容量是否还能写入数据,当发现容量不够时,此时ByteBuf会总动进行扩容;当然我们也可以手动更改ByteBuf的容量;详细见代码分析。
public static void main(String[] args) {
// 利用非池化Unpooled类创建字节缓冲区
ByteBuf byteBuf = Unpooled.buffer(2); System.out.println("initCapacity: " + byteBuf.capacity()); byteBuf.writeByte(66);
byteBuf.writeByte(67);
byteBuf.readBytes(1);
System.out.println("readerIndex: " + byteBuf.readerIndex());
System.out.println("writerIndex: " + byteBuf.writerIndex()); // 丢弃已经阅读的字节
byteBuf.discardReadBytes();
byteBuf.writeByte(68);
byteBuf.writeByte(69);
System.out.println("readerIndex: " + byteBuf.readerIndex());
System.out.println("writerIndex: " + byteBuf.writerIndex());
System.out.println("capacity: " + byteBuf.capacity());
} // 运行结果
initCapacity: 2
readerIndex: 1
writerIndex: 2
readerIndex: 0
writerIndex: 3
capacity: 64

上面代码的操作步骤:初始化ByteBuf --- 写入数据 --- 读取数据 --- 丢弃数据 --- 再写入数据;

丢弃了一个字节数的数据又写入了2个字节数的数据,初始化容量的缓冲区明显不够发生了自动扩容,扩容后的容量:64;它是怎么进行扩容的呢?什么时候扩容的呢?看下源码

public ByteBuf writeByte(int value) {
// 确保可以写入(判断是否容量够不够写入)
ensureWritable0(1);
// 设置写索引、存值
_setByte(writerIndex++, value);
return this;
} // minWritableBytes默认为1 因为writeByte每次只能写入一个字节数
final void ensureWritable0(int minWritableBytes) {
// 检查是否还有占有权和是否还有引用
ensureAccessible();
// writableBytes() = capacity - writerIndex 剩余可写容量
if (minWritableBytes <= writableBytes()) {
return;
}
// ByteBuf虽然支持自动扩容但是也有上限(Integer.MAX_VALUE)
if (minWritableBytes > maxCapacity - writerIndex) {
throw new IndexOutOfBoundsException(String.format(
"writerIndex(%d) + minWritableBytes(%d) exceeds maxCapacity(%d): %s",
writerIndex, minWritableBytes, maxCapacity, this));
} // 开始进行扩容 newCapacity = writerIndex + minWritableBytes
int newCapacity = alloc().calculateNewCapacity(writerIndex + minWritableBytes, maxCapacity); // 将新的容量写入到ByteBuf
capacity(newCapacity);
} /**
* 计算新的容量
* minNewCapacity 写入的最小容量
* maxCapacity 最大容量及Integer.MAX_VALUE 2147483647
*/
public int calculateNewCapacity(int minNewCapacity, int maxCapacity) {
if (minNewCapacity < 0) {
throw new IllegalArgumentException("minNewCapacity: " + minNewCapacity + " (expected: 0+)");
}
if (minNewCapacity > maxCapacity) {
throw new IllegalArgumentException(String.format(
"minNewCapacity: %d (expected: not greater than maxCapacity(%d)",
minNewCapacity, maxCapacity));
}
// 4兆大小 4194304
final int threshold = CALCULATE_THRESHOLD; // 4 MiB page if (minNewCapacity == threshold) {
return threshold;
} // 如果超过了4兆,
if (minNewCapacity > threshold) {
// 新的容量扩容为超过的倍数的容量
int newCapacity = minNewCapacity / threshold * threshold;
// 如果超过了最大的容量则直接设置为最大容量
if (newCapacity > maxCapacity - threshold) {
newCapacity = maxCapacity;
} else {
newCapacity += threshold;
}
return newCapacity;
} // 默认扩容大小为64
int newCapacity = 64;
while (newCapacity < minNewCapacity) {
// 左移一位 newCapacity = newCapacity*2
newCapacity <<= 1;
} return Math.min(newCapacity, maxCapacity);
}

从上面的源码可知,自动扩容在4兆的范围内变化的话,每次扩容都是64 * 2的N字方(N >= 1); 一旦超过了4兆则递增倍数为(newCapacity / 4194304) * 4194304即表示的是基于4兆增长的倍数。

4. ByteBuf和ByteBuffer的区别

  • ByteBuf单独维护读写两个数组下标,ByteBuffer只有一个下标索引,读写的时候需要手动设置(调用flip和rewind)
  • ByteBuffer不支持动态扩容(final型),ByteBuf支持动态扩容上限为Integer.MAX_VALUE
  • ByteBuf支持创建对外内存(direct内存)存储数据
  • ByteBuf支持的Api更加丰富

虽然Netty中使用的ByteBuf来进行缓存字节数据,但是最后在Channel中还是以ByteBuffer(java nio)来进行传输

参考文献:

https://www.cnblogs.com/stateis0/p/9062152.html
https://www.jianshu.com/p/1585e32cf6b4
https://blog.csdn.net/ZBylant/article/details/83037421

Netty学习篇⑥--ByteBuf源码分析的更多相关文章

  1. Netty之旅三:Netty服务端启动源码分析,一梭子带走!

    Netty服务端启动流程源码分析 前记 哈喽,自从上篇<Netty之旅二:口口相传的高性能Netty到底是什么?>后,迟迟两周才开启今天的Netty源码系列.源码分析的第一篇文章,下一篇我 ...

  2. MQTT再学习 -- MQTT 客户端源码分析

    MQTT 源码分析,搜索了一下发现网络上讲的很少,多是逍遥子的那几篇. 参看:逍遥子_mosquitto源码分析系列 参看:MQTT libmosquitto源码分析 参看:Mosquitto学习笔记 ...

  3. Nginx学习笔记4 源码分析

    Nginx学习笔记(四) 源码分析 源码分析 在茫茫的源码中,看到了几个好像挺熟悉的名字(socket/UDP/shmem).那就来看看这个文件吧!从简单的开始~~~ src/os/unix/Ngx_ ...

  4. 【.NET Core项目实战-统一认证平台】第八章 授权篇-IdentityServer4源码分析

    [.NET Core项目实战-统一认证平台]开篇及目录索引 上篇文章我介绍了如何在网关上实现客户端自定义限流功能,基本完成了关于网关的一些自定义扩展需求,后面几篇将介绍基于IdentityServer ...

  5. springMVC源码学习之addFlashAttribute源码分析

    本文主要从falshMap初始化,存,取,消毁来进行源码分析,springmvc版本4.3.18.关于使用及验证请参考另一篇jsp取addFlashAttribute值深入理解即springMVC发r ...

  6. Netty中的ChannelPipeline源码分析

    ChannelPipeline在Netty中是用来处理请求的责任链,默认实现是DefaultChannelPipeline,其构造方法如下: private final Channel channel ...

  7. Redis学习——ae事件处理源码分析

    0. 前言 Redis在封装事件的处理采用了Reactor模式,添加了定时事件的处理.Redis处理事件是单进程单线程的,而经典Reator模式对事件是串行处理的.即如果有一个事件阻塞过久的话会导致整 ...

  8. Java多线程学习之ThreadLocal源码分析

    0.概述 ThreadLocal,即线程本地变量,是一个以ThreadLocal对象为键.任意对象为值的存储结构.它可以将变量绑定到特定的线程上,使每个线程都拥有改变量的一个拷贝,各线程相同变量间互不 ...

  9. 大数据学习--day14(String--StringBuffer--StringBuilder 源码分析、性能比较)

    String--StringBuffer--StringBuilder 源码分析.性能比较 站在优秀博客的肩上看问题:https://www.cnblogs.com/dolphin0520/p/377 ...

随机推荐

  1. MAC idea&eclipse快捷键--笔记

    idea: 1. command+d 删除行 2. command+o 重写父类方法 3. option+command+v  自动补全变量 4. 将普通java项目变成maven项目 在项目名称上点 ...

  2. C++ 11标准

    C++11,也称为C++0x.为目前C++编程语言的最新正式标准(ISO/IEC 14882:2011).它将取代第二版标准ISO/IEC 14882:2003(第一版ISO/IEC 14882:19 ...

  3. 实战SpringCloud响应式微服务系列教程(第十章)响应式RESTful服务完整代码示例

    本文为实战SpringCloud响应式微服务系列教程第十章,本章给出响应式RESTful服务完整代码示例.建议没有之前基础的童鞋,先看之前的章节,章节目录放在文末. 1.搭建响应式RESTful服务. ...

  4. Unity资源加载路径及加载方式小结

    Unity3D中的资源路径路径属性 路径说明Application.dataPath 此属性用于返回程序的数据文件所在文件夹的路径.例如在Editor中就是Assets了.Application.st ...

  5. C语言I作业06

    问题 答案 这个作业属于那个课程 C语言程序设计II 这个作业要求在哪里 https://edu.cnblogs.com/campus/zswxy/CST2019-4/homework/9888 我在 ...

  6. CSPS模拟 92

    为什么每次我的flag都会倒? skyh:12:15之前你把T2改出来我吃屎. ----12:10 于是12:12把线段树打完 12:13把主函数打完,过样例,带着一个sb错误交了,WA飞. 然后我就 ...

  7. 分布式系统中session一致性问题

    业务场景 在单机系统中,用户登陆之后,服务端会保存用户的会话信息,只要用户不退出重新登陆,在一段时间内用户可以一直访问该网站,无需重复登陆.用户的信息存在服务端的 session 中,session中 ...

  8. UiPath之Word转换为PDF

    前几天在手机上看到其他的文章,里面提到如何将Word转换为PDf,在UiPath的ManagePackage中,下载一个WordToPDF的包, 我按照上面的方法试着做了一下,但是在转换的时候很不稳定 ...

  9. jquery序列帧播放(支持视频自动播放和不是全屏播放)

    jquery序列帧播放 这个弊端就是到时候需要升级下带宽 至少10MB 保证不卡.. ae导出序列真的时候 每秒10帧 就是代码每秒播放10张图片 尺寸适当的可以压小点<pre> < ...

  10. Appium+python自动化(四十)-Appium自动化测试框架综合实践 - 代码实现(超详解)

    1.简介 今天我们紧接着上一篇继续分享Appium自动化测试框架综合实践 - 代码实现.由于时间的关系,宏哥这里用代码给小伙伴演示两个模块:注册和登录. 2.业务模块封装 因为现在各种APP的层出不群 ...