Qt 中的网络文件下载

        在电子白板的系统层,我们用 Qt 实现重点是从网络上下载文件,具体的任务有:

  • 用 QNetworkManager 下载网络文件
  • 实现一个特殊的 QIODevice 管理数据流

网络处理

        从网络下载文件会面临许多的问题:网络暂时不通、TCP连接卡顿、连接断开,用户会等待焦虑,被失败搞得焦头烂额。

        所以文件下载需要支持卡顿检测、进度汇报,断点续传,更进一步的需要有取消下载,优先下载(次要任务规避)等功能。

        为什么要管理数据流呢?QNetworkAccessManager 提供的 QNetworkReply 本身也是一个 QIODevice 数据流,但是一旦重试,新的请求会是一个新的 QNetworkReply 对象,你肯定不希望外部使用你这个下载功能的人,去管理这多个 QNetworkReply 对象吧,所以需要实现一个代理形式的 QIODevice 数据流,这是一个派生实现 QIODevice 的实用场景。

        启动一个网络文件下载请求,在 Qt 用 QNetworkManager  实现,最简单的代码是这样的:

void HttpStream::open(QUrl url)
{
    static QNetworkAccessManager manager;
    QNetworkRequest request(url);
    QNetworkReply* reply(manager.get(request));
    reply_ = reply;
    reopen();
}

void HttpStream::reopen()
{
    QObject::connect(reply_, &QNetworkReply::finished, this, &HttpStream::onFinished);
    QObject::connect(reply_, &QNetworkReply::readyRead, this, &HttpStream::onReadyRead);
    void (QNetworkReply::*errorOccurred)(QNetworkReply::NetworkError) = 
    QObject::connect(reply_, errorOccurred, this, &HttpStream::onError);
}

         这里的 QNetworkAccessManager 肯定是不能销毁的,一旦销毁,请求就不会执行下去。所以需要使用 static 关键字定义对象,但是还是建议结合其他的考虑,用更好的方式来管理 manager 对象的生命期。QNetworkAccessManager 管理了 HTTP 连接池和内部线程池,所以一般情况下,为了复用这些资源,可以考虑采用全局单例的模式。

        这段代码还引出了 HttpStream 类,它就是派生 QIODevice 实现代理形式的数据流类。 

        QNetworkAccessManager 的 get 方法发出 HTTP GET 请求,显然 post 方法发出 POST 请求,HTTP 还有 PUT、DELETE、HEAD 等请求类型,如果不了解的话,可以搜索一下相关资料。

        QNetworkReply 对象上有三个常用的信号(finished,readyRead,error,readyRead 实际上是 QIODevice 的信号),我们用三个方法(onFinished,onReadyRead,onError)来分别处理。

        在三个回调方法中,并不需要线程同步,因为 Qt 的“信号-槽”技术,会保证在接收信号对象的关联线程中调用信号回调。其中 finished 信号肯定是最后触发的,也肯定会有,所以一般在 onFinished 中释放 QNetworkReply 对象。代码如下:

void HttpStream::onFinished()
{
    sender()->deleteLater();
}

        需要注意的是,你不能立即 delete 该对象,而是要通过 deleteLater 延迟释放。这是 QNetworkReply 的一个坑,一不小心就会导致莫名其妙的 Crash。因为 QNetworkReply 在 发出 finished 信号后,还会做一些自己的收尾动作,而在 C++ 中继续访问一个被 delete 了的对象,是很危险的行为。(更新:Qt 5.15 已经修复了这个问题)

        在对 error 信号的处理中,我们用了一个特殊的成员函数指针语法(如下),为什么要这样麻烦呢?因为 QNetworkReply 的 error 方法是一个重载方法,另外一个不带参数的方法返回保存的错误码,不是一个信号(signals)方法。但是建立信号连接的 connect 方法无法判断应该用哪个,通过这种方式可以帮助编译器做出选择。(更新:Qt 5.15 已经意识到这个问题,将 error 信号名称改成了 errorOccurred)

void (QNetworkReply::*errorOccurred)(QNetworkReply::NetworkError) = &QNetworkReply::error;

        对  error 信号的处理,主要工作是重试网络请求,实现代码大致如下:

void HttpStream::onError(QNetworkReply::NetworkError e)
{
    if (e <= QNetworkReply::UnknownNetworkError
            || e >= QNetworkReply::InternalServerError) {
        QNetworkRequest request = reply_->request();
        QNetworkReply * reply = reply_->manager()->get(request);
        std::swap(reply, reply_);
        reopen(); // 重新建立信号连接
        return;
    }
    setErrorString(reply_->errorString());
    emit error(e);
}

         首先,并不是所有的错误都需要重试,常见的错误一般是网络方面的错误(错误码 =< UnknownNetworkError)或者服务端异常错误(错误码 >= InternalServerError),其他的错误一般是内容不存在或者客户端的问题,重试也是没有效果的。

        然后,重试网络请求,就是把请求重新发送一遍,通过老的 QNetworkReply 对象可以取到之前的 QNetworkRequest 以及 manager 对象。

        在处理网络连接时,一般少不了定时器的使用。使用定时器,可以检测网络卡顿,卡死,帮助更快速的重试恢复网络传输。网络 Socket API 一般需要很长时间(最长可能有65分钟)才能通知 TCP 连接断开,用户显然是不能仍受这么长时间的。所以通过周期性的定时器,定期计算网络传输质量,可以及时放弃质量差的 TCP 连接,通过重新建立连接有可能会改善传输质量。

        在 Qt 中实现定时器一般有两种方法:一个是 QObject 的 timerEvent,通过 startTimer(int) 启动一个定期的 QTimeEvent 事件流;另一个是使用 QTimer 对象,但是本质上还是定期的 QTimeEvent。与信号回调一样,timerEvent 的处理线程必定是 QObject 对象的关联线程,所以这里仍然不需要处理线程同步。

        当检测到网络卡顿、卡死(比如 1 分钟内滑动平均速度小于 10K,或者 1 秒内没有数据),就需要主动重新发送请求,这通过取消(abort)上一个请求来实现。如果外部取消整个下载任务,也是通过 abort 取消请求,为了区分两种情况,需要引入一个变量记录一下。

void HttpStream::timerEvent(QTimerEvent *)
{
    if (...) {
        reply_->abort();
    }
}

        当 QNetworkReply 被 abort 之后,会发出 error,finished 信号,这就回到之前的信号处理方法中了。 

网络数据流

        以上我们实现了 HTTP 文件下载的网络连接管理,接下来看看数据传输的实现方法。

        我们通过类 HttpStream 继承 QIODevice,实现了一个自定义的数据流。数据流有读写两个管道,这里我们只需要实现读管道就行了,最关键的是实现 readData 方法。因为 QNetworkReply  管理的数据,所以只要转发调用就完成了。

qint64 HttpStream::readData(char *data, qint64 maxlen)
{
    return reply_->read(data, maxlen);
}

        对于写数据(writeData),我们安排一个空的实现:

qint64 HttpStream::writeData(const char *, qint64)
{
    assert(false);
    return 0;
}

        方法 readData 是 QIODevice 内部逻辑在调用,QIODevice 还会帮助我们记录读指针的位置,所以不需要操心 pos() 的更新。

         当外部没有及时将数据读走时,QNetworkReply 会保存数据。但是一旦重试请求,老的 reply 就丢弃了,这部分数据就丢失了,所以下一次请求需要重复下载这部分数据。然而,尝试在 HttpStream  中保存这部分数据,会导致实现 QIODevice 相关接口方法的方案变得比较复杂,并且网络卡顿时,一般不会有数据没有被读取,所以我们不做这个优化。大家有兴趣的话,作为练习,可以考虑一下怎么实现。

        我们实现的是随机访问(random-access)模式的数据流,所以最好让 size() 方法能够正常工作。HTTP 协议一般会在应答的 ContentLength 头域中提供文件大小,所以可以这么实现:

qint64 HttpStream::size() const
{
    return reply_->header(QNetworkRequest::ContentLengthHeader).toLongLong();
}

        怎么支持断点续传的功能呢?其实对于 QIODevice 来说,这就是一个随机读取的功能,先通过 seek() 定位读指针到某个位置,然后继续 read()。所以实现一下 seek() 方法:

bool HttpStream::seek(qint64 pos)
{
    if (!QIODevice::seek(pos))
        return false;
    reply_->abort();
    return true;
}

        因为读指针的位置是在 QIODevice 中维护的,所以调用一下基类的 seek 是有必要的。其他我们需要做的只是 abort 当前的 QNetworkReply 就可以了。回忆一下上面的逻辑, abort 是不是会触发 HTTP 请求重试。那么,在重试的时候,从新的位置下载就可以了。

void HttpStream::onError(QNetworkReply::NetworkError e)
{
    if (e <= QNetworkReply::UnknownNetworkError
            || e >= QNetworkReply::InternalServerError) {
        QNetworkRequest request = reply_->request();
        qint64 size = pos();
        if (size > 0)
            request.setRawHeader("Range", "bytes=" + QByteArray::number(size) + "-");
        QNetworkReply * reply = reply_->manager()->get(request);
        std::swap(reply, reply_);
        reopen(); // 重新建立信号连接
        return;
    }
    setErrorString(reply_->errorString());
    emit error(e);
}

        把之前重试请求的代码拿出来,增加数据位置相关的处理。如果读指针的位置不是 0,就添加 Range 头域(请参考 HTTP 协议),让服务器从指定位置给我们发送数据,从而实现了断点续传的功能。

上一篇:LeetCode 875. 爱吃香蕉的珂珂(二分查找)


下一篇:Stream、FileStream、MemoryStream的区别