苹果系统中的AirDrop功能
AirDrop is an ad-hoc service in Apple Inc.'s iOS and macOS operating systems, introduced in Mac OS X Lion and iOS 7, which enables the transfer of files among supported Macintosh computers and iOS devices over Wi-Fi and Bluetooth, without using mail or a mass storage device.----wikipedia
上面是对苹果AirDrop功能的描述。苹果公司的AirDrop可以实现点对点的高速传输, 其传输不依赖线路或局域网,它的大概原理是通过蓝牙通讯发现周围设备,然后通过设备的无线网卡组件一个ad-hoc网络,通过这个网络进行socket传输文件。由于传输过程不经过路由器转发,所以它的传输速度不依赖路由器的速度以及信号等,从而实现超越局域网传送文件速度的,接近于设备IO上限速度的高速传输。
它的技术实现原理大概分为两部分:1、发现设备、组件临时局域网 2、通过socket连接实现文件的传输。我们可以看到第二步是标准的socket功能,是平台无关的,关键在于第1步;如果Android可以支持这种样的功能,就可以实现同样的功能。
值得一提的是,为什么这种方式传输文件可以达到远高于局域网的传输速度呢?
组建临时局域网后进行传输的速度基本瓶颈就取决于设备无线模块支持的程度了。如802.11n传输速度理论可以高达150Mb/s,而 802.11ac最高理论速度可以达到866.7Mb/s(参考)。这样的速度恐怕已经超过了很多设备所用的存储介质的写速度,所以最高传输速度是设备写入速度和无线模块的传输速度中的最小值。
幸运的是Android在4.0(API level 14)以后也引入了类似ad-hoc组网API,通过这些API我们就可以实现Android版本的AirDrop功能。
Android Wi-Fi Direct (peer-to-peer or P2P)简介
Android4.0后,android api引入了WifiP2pManager这个类,通过这个类主要实现了Wi-Fi Direct规定的功能,它是wifi联盟技术规范的一部分,该规范规定符合该规范的wifi设备可以直接彼此连接,不依赖于广域网或者局域网。通过WifiP2pManager所提供的功能,我们可以实现发现并组件临时wifi p2p网络,一旦临时局域网络组件完成,就可以实现跟AirDrop一样的功能了。这个类的功能相对来说比较简单,下面来介绍一下使用这个类的大体过程。
1、初始化对象和注册广播接受事件
mWifiP2pManager = (WifiP2pManager) getSystemService(Context.WIFI_P2P_SERVICE);
mChannel = mWifiP2pManager.initialize(this, getMainLooper(), this);
// Indicates a change in the Wi-Fi P2P status.
intentFilter.addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION);
// Indicates a change in the list of available peers.
intentFilter.addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION);
// Indicates the state of Wi-Fi P2P connectivity has changed.
intentFilter.addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION);
// Indicates this device's details have changed.
intentFilter.addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION);
recv = new WifiP2pReceiver(mWifiP2pManager, mChannel, this);
通过getSystemService我们可以拿到WifiP2pManager的对象,然后调用它的初始化函数得到WifiP2pManager.Channel的对象(这个对象)是后续操作的输入参数。
然后注册4个receiver事件,用来接收来自系统的广播。其中WifiP2pReceiver的实现如下:
public class WifiP2pReceiver extends BroadcastReceiver {
private WifiP2pManager mWifiP2pManager;
private WifiP2pManager.Channel mChannel;
private WifiP2pActionListener mListener;
public WifiP2pReceiver(WifiP2pManager wifiP2pManager, WifiP2pManager.Channel channel,
WifiP2pActionListener listener) {
mWifiP2pManager= wifiP2pManager;
mChannel= channel;
mListener = listener;
}
@Override
public void onReceive(Context context, Intent intent) {
Log.e("wifi-p2p", "接收到广播: " + intent.getAction());
int state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1);
switch (intent.getAction()) {
//WiFi P2P是否可用
case WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION:
if (state == WifiP2pManager.WIFI_P2P_STATE_ENABLED) {
mListener.wifiP2pEnabled(true);
} else {
mListener.wifiP2pEnabled(false);
}
break;
// peers列表发生变化
case WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION:
mWifiP2pManager.requestPeers(mChannel, new WifiP2pManager.PeerListListener() {
@Override
public void onPeersAvailable(WifiP2pDeviceList peers) {
mListener.onPeersInfo(peers.getDeviceList());
}
});
break;
// WiFi P2P连接发生变化
case WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION:
NetworkInfo networkInfo = intent.getParcelableExtra(WifiP2pManager.EXTRA_NETWORK_INFO);
if (networkInfo.isConnected()){
mWifiP2pManager.requestConnectionInfo(mChannel, new WifiP2pManager.ConnectionInfoListener() {
@Override
public void onConnectionInfoAvailable(WifiP2pInfo info) {
mListener.onConnection(info);
}
});
}else {
mListener.onDisconnection();
}
break;
// WiFi P2P设备信息发生变化
case WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION:
WifiP2pDevice device = intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE);
mListener.onDeviceInfo(device);
break;
default:
break;
}
}
}
2、然后就可以通过WifiP2pManager中提供的api来发现和连接设备。
下面列举一下关键的api:
discoverPeers用来搜索周边设备。
connect用来连接上一步中发现的设备。
以上两个管家api的结果都会通过receiver接收到,由程序处理后做相应的UI展示和程序动作。
api的更详细介绍可以参加google的开发文档:https://developer.android.com/guide/topics/connectivity/wifip2p
通过上述两个步骤后两台(或更多台)设备就建立起来一个虚拟的临时局域网,我们可以通过ifconfig命令查看到设备新增了一个网络接口,并且网段是192.168.49.x
后续设备之间就可以通过这个同网段的ip地址进行互联互通。
值得说明的是,这个过程只需要将设备的wifi开启即可,并不需要设备wifi连接到某一个无线路由,仅仅保持wifi打开即可。所以这个功能比较酷的地方是在荒郊野外也可以通过wifi组件临时网络,进行文件共享。
通用文件传输的设计
组网顺利完成,后续保证高速传输、可靠传输就要依赖于传输的设计和实现了。实际上我们接下来要设计的就是一个传统的下载程序,传输文件一方可以看做是server端,接收文件的一端可以看做是client端。不过这中间有几个产品化需要考虑的重点:1、超大文件传输 2、速度如何达到峰值 3、实现要通用,适用于局域网和广域网。
第一个问题带来的问题是需要断点续传来保障大文件可以失败续传,节省时间。这里还衍生出来下载文件信息的格式记录问题,如何描述一个未下载完成的文件。
第二个问题结合第三个问题的话则需要多线程传输,以此来加快网络传输的效率。
第三个问题要求功能的设计通用,与临时局域网络本身没有耦合。
由于要实现通用,所以这一部分的所有功能都采用linux/unix原生开发,已适应多种设备和运行环境。
多线程下载的设计
多线程下载的最大问题是如何组织文件形式,即如何并发处理下载的数据。下面表格对比了几种可能的方式:
上表中列出了三种可能的方案,其中第三种单文件多线程无锁写入从理论上看是最佳的,问题是如何实现它。要弄清这个问题,我们要先看一下我们所在平台的文件相关操作的实现,即unix/linux上文件是如何组织的。
并行写入方式的设计
在*nx上,
- 每个进程在进程表中都有一个记录项,记录项中包含有一张打开文件描述符表,可将其视为一个矢量,每个描述项占用一项。
- 内核为所有打开文件维持一张文件表。每个文件表项包含:
1). 文件状态标志:读、写、添写、同步、非阻塞等
2). 当前文件偏移量
3). 指向该文件v节点表项的指针 -
每个打开文件(或设备)都有个(唯一一个)i/v节点结构。i/v节点包含了文件类型和对此文件进行各种操作的函数指针。
对于大多数文件,i/v节点还包含了该文件的i节点(索引节点)。
这些信息是在打开文件时从磁盘上读入内存的,所以所有关于文件的信息都是快速可供使用的。
从上面的关系看,我们打开同一文件多次会产生多个fd,多个fd在内核文件表中记录着各自读写的文件指针偏移,而这些文件表项最终指向跟硬件更加贴近的唯一文件信息i/v节点。
我们只需要维护好各个打开的fd,对文件的不同位置进行读写,是不会产生偏差的,因为在我们这种场景下,每一个fd对应的文件指针偏移是不交叉的。我们分别对各个fd调用write函数即可。
这种方式可以实现单文件多线程无锁写入,但是多个打开的fd调用write函数并不是最高效的做法,因为write函数本身还要经历一次用户内存空间拷贝到内核缓存的过程,调用write次数越多这种“损耗”越多。
更好的方式是mmap函数。
mmap函数可以将一个文件的内容映射到进程的虚拟地址空间,这样我们可以通过对内存的读写来实现文件的读写,而缓存、写入时机等都有操作系统完成,并且保证可以写入即使进程意外崩溃。
mmap没有write函数写入时的用户态内存拷贝,所以理论上讲效率是更高的。更酷的是我们可以映射同一个文件的不同部分,取得多个偏移量不同的指针,通过这些指针并行完成写入文件数据的操作,就像操作内存数据一样。
std::uint64_t offset = 0;
for (int i = 0; i < pieces; ++i)
{
std::uint64_t len = (pages / pieces) * page_size;
if (i == pieces - 1)
{
len = m_size - offset;
}
char* tmp = (char*)::mmap(nullptr, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd_data, offset);
if (tmp == MAP_FAILED)
{
std::cerr << "mmap failed" << std::endl;
}
m_piece_infos.emplace_back(offset, len, len, piece_id++, tmp);
offset += len;
}
文件格式的设计
文件格式主要指的是记录传输状态的文件格式如何设计,由于我们要保证大文件的断点续传,所以必须要有已下载信息的记录。在程序运行过程中实时的更新这个文件的信息,以便意外断连后的恢复。
上图描述下载描述文件info文件的文件格式设计。其中block是每一片文件的具体信息描述(每一个文件片即对应一个线程的操作逻辑),id表示此片文件的唯一标号(4字节描述),pos表示此文件片相对于整个文件的文件偏移量(8字节描述),lentotal表示此文件片的总长度(8字节描述),lenleft表示此文件片剩余没有传输的文件数据长度(8字节描述)。
info文件中crc32表示对整个info文件的信息数据的crc32校验值,防止文件破坏;version表示当前文件格式的版本号,便于不同版本逻辑的兼容;blocks表示分片的片数;最后是文件分片信息的顺序排列。
值得说明的是这里规定,所有数据存储(特指int和long)必须是小端存储,读写程序也都必须强制按照小端序读写info文件。这样的规定可以保证info文件的平台无关性,这样我们可以将下载到一半的文件和描述文件拷贝到任意别的设备继续下载而不影响下载续传。
为了保证小端序的读写,读写info文件必须采用强制小端序。
#define PUT_LE_32(int32value, pbuff) pbuff[0] = (std::int8_t)(int32value & 0xff);\
pbuff[1] = (std::int8_t)((int32value >> 8) & 0xff);\
pbuff[2] = (std::int8_t)((int32value >> 16) & 0xff);\
pbuff[3] = (std::int8_t)((int32value >> 24) & 0xff)
#define PUT_LE_64(int64value, pbuff) pbuff[0] = (std::int8_t)(int64value & 0x00000000000000ff);\
pbuff[1] = (std::int8_t)((int64value >> 8) & 0x00000000000000ff);\
pbuff[2] = (std::int8_t)((int64value >> 16) & 0x00000000000000ff);\
pbuff[3] = (std::int8_t)((int64value >> 24) & 0x00000000000000ff);\
pbuff[4] = (std::int8_t)((int64value >> 32) & 0x00000000000000ff);\
pbuff[5] = (std::int8_t)((int64value >> 40) & 0x00000000000000ff);\
pbuff[6] = (std::int8_t)((int64value >> 48) & 0x00000000000000ff);\
pbuff[7] = (std::int8_t)((int64value >> 56) & 0x00000000000000ff)
#define GET_LE_32(pbuff, int32value) int32value = ((std::int32_t)pbuff[0] & 0xff) |\
(((std::int32_t)pbuff[1] & 0xff) << 8) |\
(((std::int32_t)pbuff[2] & 0xff) << 16) |\
(((std::int32_t)pbuff[3] & 0xff) << 24)
#define GET_LE_64(pbuff, int64value) int64value = ((std::int64_t)pbuff[0] & 0x00000000000000ff) |\
(((std::int64_t)pbuff[1] & 0x00000000000000ff) << 8) |\
(((std::int64_t)pbuff[2] & 0x00000000000000ff) << 16) |\
(((std::int64_t)pbuff[3] & 0x00000000000000ff) << 24) |\
(((std::int64_t)pbuff[4] & 0x00000000000000ff) << 32) |\
(((std::int64_t)pbuff[5] & 0x00000000000000ff) << 40) |\
(((std::int64_t)pbuff[6] & 0x00000000000000ff) << 48) |\
(((std::int64_t)pbuff[7] & 0x00000000000000ff) << 56)
如果不这样强制读写32位和64位值的话,直接按内存读取或写入32/64位值,会因cpu架构不同而产生不同的值。另外还有语言的差异性,比如java就是大端序语言,而大多数平台+c语言是小端序。
传输协议的设计
我们的传输是基于TCP协议传输的,所以有关上层业务层逻辑就需要我们制定一个简单的协议来实现文件信息交换、文件分片信息交换、文件片传输等。这些信息还要满足断点续传的需要。
以上是协议执行的一个时序图,其中分片信息部分是多线程并发的。
具体协议包含两个部分:
1、发送方发送文件整体信息,接收方根据文件信息完成文件的分片计算,将计算结果返回给发送方。接收方、发送方分别关闭连接等待第二阶段信息确认。
2、发送方根据接收方返回的文件分片信息对文件进行分片,开辟相应的线程传输相应的分片数据。传送分片数据前,各个分片传输线程要完成分片信息确认,接收方确认了分片信息,就可以后续传输数据了。
经过上面阶段协议协商,接收方和发送方就建立了发送通道,接下来就是发送数据的过程。
如果传送数据的过程被打断,那么新建连接后还会重新走这两阶段协议,这时接收方就可以将已接收到的数据返回给发送方,双方协商完毕后,就会进行数据的续传。
在整个数据传送完毕后,接收方会根据协议开头保存的md5值来校验文件的完整性,完整性通过那么文件就真正的传输完成了。
应用
手机对手机传输文件也是一大痛点,如果手机间想共享一部高清电影,就可以采用本文的实现,通过wifi-p2p网络直接高速分享电影,即便在没有局域网的室外也可以高速共享。
最后
有码有真相