AFNetworking 2.0 Преобразование NSURLSessionDataTask в NSURLSessionDownloadTask не записывает все данные файла на диск

При преобразовании NSURLSessionDataTask в NSURLSessionDownloadTask мы сталкиваемся с потерей данных. В частности, в файлах размером более 16 КБ мы теряем первые 16 КБ (ровно 16384 байта). Файл, который записывается на диск, короток на длину исходного ответа....

Длинный пост, спасибо за чтение и любые предложения.

ОБНОВЛЕНИЕ 2014-09-30 — Окончательное исправление

Поэтому недавно я снова столкнулся с таким же поведением и решил копнуть глубже. Как оказалось, Matt T (автор AFNetworking) опубликовал коммит, который изменяет метод AFURLSessionManager -respondsToSelector, и он вернет NO, если какой-либо из ДОПОЛНИТЕЛЬНЫХ вызовов делегата не установлен как Blocks. Коммит находится здесь (проблема № 1779): https://github.com/AFNetworking/AFNetworking/commit/6951a26ada965edc6e43cf83a4985b88b0f514d2.

Итак, способ, которым вы ДОЛЖНЫ использовать необязательные делегаты, - это вызвать метод -setTaskDidReceiveAuthenticationChallengeBlock: (вызовите один для yнеобязательный делегат, который вы хотите использовать) с вашим блоком ВМЕСТО переопределения метода -URLSession:dataTask:didReceiveResponse:completionHandler: в вашем подклассе. Выполнение этого дает ожидаемый результат.

Настраивать:

Мы пишем приложение для iOS, которое загружает файлы с веб-сервера. Файлы защищены php-скриптом, который аутентифицирует запрос от клиента iOS.

Мы используем AFNetworking 2.0+ и выполняем начальную операцию POST (NSURLSessionDataTask) в API, отправляя учетные данные пользователя и тому подобное. вот первоначальный запрос:

NSURLSessionDataTask *task = [self POST:API_FULL_SYNC_GETFILE_PATH parameters:body success:^(NSURLSessionDataTask *task, id responseObject){ .. }];

У нас есть собственный класс, который наследуется от класса AFHTTPSessionManager, в котором содержится весь код iOS в этой задаче.

Сервер получает этот запрос и аутентифицирует пользователя. Одним из параметров POST является файл, который пытается загрузить клиент. Сервер находит файл и выдает его. Чтобы упростить начало, я удалил аутентификацию и некоторые заголовки управления кешем, но вот серверный php-скрипт, который работает:

$file_name = $callparams['FILENAME'];
$requested_file = "$sync_data_dir/$file_name";

@apache_setenv('no-gzip', 1);
@ini_set('zlib.output_compression', 'Off');
set_time_limit(0);`

$file_size = filesize($requested_file);
header("Content-Type: application/gzip");
header("Content-Transfer-Encoding: Binary");
header("Content-Length: {$file_size}");
header("Content-Disposition: attachment; filename=\"{$file_name}\"");

$read_bytes = readfile($requested_file);

Файлы всегда имеют расширение .gz.

Вернувшись на клиент, получен ответ и вызывается метод -URLSession:dataTask:didReceiveResponse:completionHandler: из NSURLSessionDataDelegate. Определяем тип MIME и переключаем задачу на задачу загрузки:

-(void)URLSession:(NSURLSession *)session
         dataTask:(NSURLSessionDataTask *)dataTask
didReceiveResponse:(NSURLResponse *)response
completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler
{
    [super URLSession:session dataTask:dataTask didReceiveResponse:response completionHandler:completionHandler];

    /*
     This transforms a data task into a download taks for certain API calls. Check the headers to determine what to do
     */
    if ([response.MIMEType isEqualToString:@"application/gzip"]) {
        // Convert to download task
        completionHandler(NSURLSessionResponseBecomeDownload);
        return;
    }
    // continue as-is
    completionHandler(NSURLSessionResponseAllow);

}

Вызывается метод -URLSession:dataTask:didBecomeDownloadTask:. Мы используем этот метод, чтобы связать задачу данных и задачу загрузки с использованием идентификатора. Это делается для отслеживания результатов задачи загрузки в обработчике завершения задачи данных. Не очень важно для проблемы, но вот код:

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didBecomeDownloadTask:(NSURLSessionDownloadTask *)downloadTask
{
    [super URLSession:session dataTask:dataTask didBecomeDownloadTask:downloadTask];

    // Relate the data task with the download task.
    if (!_downloadTaskIdToDownloadIdTaskMap) {
        _downloadTaskIdToDownloadIdTaskMap = [NSMutableDictionary dictionary];
    }
    [_downloadTaskIdToDownloadIdTaskMap setObject:@(dataTask.taskIdentifier) forKey:@(downloadTask.taskIdentifier)];
}

Где проявляется проблема:

В методе -URLSession:downloadTask:didFinishDownloadingToURL: размер записываемого временного файла меньше длины содержимого.

Что мы обнаружили:

A) Если мы реализуем метод URLSession:dataTask:didReceiveData: класса NSURLSessionTaskDelegate, мы наблюдаем ровно 1 вызов для каждого файла, который мы пытаемся загрузить. Если файл больше 16384 байт, то результирующий временный файл будет короче на эту величину. Поместив запись журнала в этот метод, мы видим, что длина параметра данных составляет 16384 байта для файлов большего размера.

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data
{
    [super URLSession:session dataTask:dataTask didReceiveData:data];

    NSMutableDictionary *dataTaskDetails = [_dataTaskDetails objectForKey:@(dataTask.taskIdentifier)];
    NSString *fileName  = dataTaskDetails[@"FILENAME"];

    DDLogDebug(@"Data recieved for file '%@'. Data length %d",fileName,data.length);
}

Б) Размещая запись журнала в методе URLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite: класса NSURLSessionDownloadDelegate, мы наблюдаем 1 или более вызовов этого метода для каждого файла, который мы пытаемся загрузить. Если размер файла ‹16K, то отображается только один вызов. Если файл больше 16 КБ, мы получаем больше звонков. Вот этот метод:

- (void)URLSession:(NSURLSession *)session
      downloadTask:(NSURLSessionDownloadTask *)downloadTask
      didWriteData:(int64_t)bytesWritten
 totalBytesWritten:(int64_t)totalBytesWritten
totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite
{
    [super URLSession:session downloadTask:downloadTask didWriteData:bytesWritten totalBytesWritten:totalBytesWritten totalBytesExpectedToWrite:totalBytesExpectedToWrite];

    id dataTaskId = [_downloadTaskIdToDownloadIdTaskMap objectForKey:@(downloadTask.taskIdentifier)];
    NSMutableDictionary *dataTaskDetails = [_dataTaskDetails objectForKey:dataTaskId];
    NSString *fileName  = dataTaskDetails[@"FILENAME"];
    DDLogDebug(@"File '%@': Wrote %lld bytes. Total %lld of %lld bytes written.",fileName,bytesWritten,totalBytesWritten,totalBytesExpectedToWrite);
}

В качестве примера ниже приведен вывод консоли для одного файла «members.json.gz». Я добавил комментарии, чтобы выделить важную строку.

[2014-02-24 00:54:16:290][main][I][APIClient.m:syncFullGetFile:withSyncToken:andUserName:andPassword:andCompletedBlock:][Line: 184] API Client requesting file 'members.json.gz' for session with token 'MToxMzkzMjIxMjM4'. <-- This is the initial request for the file.
[2014-02-24 00:54:17:448][NSOperationQueue 0x14eb6380][?][APIClient.m:URLSession:dataTask:didReceiveData:][Line: 542] Data recieved for file 'members.json.gz'. Data length 16384 <-- Initial response, seems to fire BEFORE the conversion to a download task.
[2014-02-24 00:54:17:487][NSOperationQueue 0x14eb6380][?][APIClient.m:URLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:][Line: 521] File 'members.json.gz': Wrote 16384 bytes. Total 16384 of 92447 bytes written. <-- Now the data task is a download task.
[2014-02-24 00:54:17:517][NSOperationQueue 0x14eb6380][?][APIClient.m:URLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:][Line: 521] File 'members.json.gz': Wrote 16384 bytes. Total 32768 of 92447 bytes written.
[2014-02-24 00:54:17:533][NSOperationQueue 0x14eb6380][?][APIClient.m:URLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:][Line: 521] File 'members.json.gz': Wrote 16384 bytes. Total 49152 of 92447 bytes written.
[2014-02-24 00:54:17:550][NSOperationQueue 0x14eb6380][?][APIClient.m:URLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:][Line: 521] File 'members.json.gz': Wrote 16384 bytes. Total 65536 of 92447 bytes written.
[2014-02-24 00:54:17:568][NSOperationQueue 0x14eb6380][?][APIClient.m:URLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:][Line: 521] File 'members.json.gz': Wrote 10527 bytes. Total 76063 of 92447 bytes written. <-- Total is short by same 16384 - same number as the initial response.
[2014-02-24 00:54:17:573][NSOperationQueue 0x14eb6380][?][APIClient.m:URLSession:downloadTask:didFinishDownloadingToURL:][Line: 472] Temp file size for 'members.json.gz' is 76063
[2014-02-24 00:54:17:573][NSOperationQueue 0x14eb6380][?][APIClient.m:URLSession:downloadTask:didFinishDownloadingToURL:][Line: 485] File 'members.json.gz' downloaded. Reported 92447 of 92447 bytes received.
[2014-02-24 00:54:17:574][NSOperationQueue 0x14eb6380][?][APIClient.m:URLSession:downloadTask:didFinishDownloadingToURL:][Line: 490] File size after move for 'members.json.gz' is 76063
[2014-02-24 00:54:17:574][NSOperationQueue 0x14eb6380][E][APIClient.m:URLSession:downloadTask:didFinishDownloadingToURL:][Line: 497] Expected size of file 'members.json.gz' is 92447 but size on disk is 76063. Temp file size is 0.

Помощь:

Мы считаем, что делаем что-то не так. Возможно, заголовки, которые мы отправляем с сервера, плохо сочетаются с переключателем data-to-download-task. Возможно, мы неправильно используем AFNetworking.

Кто-нибудь знает об этом поведении? Должны ли мы захватить исходное тело ответа в URLSession:dataTask:didReceiveData: до того, как задача переключится на задачу загрузки?

Что действительно странно, так это то, что если размер файла меньше 16 КБ, проблем не возникает. Весь файл пишется.

ВСЕ запросы на файлы начинаются как задачи данных и преобразуются в задачи загрузки.


person Eric Risler    schedule 24.02.2014    source источник


Ответы (1)


Я могу преобразовать NSURLSessionDataTask в NSURLSessionBackgroundTask, и (а) файл имеет правильный размер, и (б) я не вижу вызовов didReceiveData.

Я заметил, что вы вызываете super экземпляров этих различных методов делегата. Это немного любопытно. Интересно, вызывает ли ваша super реализация didReceiveResponse сам обработчик завершения, что приводит к тому, что вы дважды вызываете этот обработчик завершения. Примечательно, что я могу воспроизвести вашу проблему, если намеренно вызову обработчик дважды, один раз с NSURLSessionResponseAllow, а затем снова с NSURLSessionResponseBecomeDownload.

Убедитесь, что вы вызываете свой обработчик завершения только один раз и будьте очень осторожны с тем, что у вас есть в этих super методах (или просто удалите ссылку на них вообще).

person Rob    schedule 24.02.2014
comment
Удивительно. Хорошо сделано. В качестве примечания для всех, кто приходит сюда, документация AFNetworking здесь указывает, что если вы создаете подкласс класса AFURLSessionManager и переопределяете любой из специально перечисленных методов делегата, вы должны сначала вызвать суперреализацию. Реализация super в версии 2.1.0 по умолчанию вызывает обработчик завершения, передавая NSURLSessionResponseAllow. Я удалил вызов super, и код работает нормально. - person Eric Risler; 25.02.2014
comment
@EricRisler Да, или глядя на источник AFNetworking, похоже, что вы можете вызвать setDataTaskDidReceiveResponseBlock и определить, как вы хотите реагировать на обработчик завершения. - person Rob; 25.02.2014
comment
Правильный. Несколько не столь очевидное поведение реализации, учитывая, что разработчик может использовать либо блоки, либо делегаты, либо и то, и другое. Если вы не используете блоки в этом случае, вы получите это странное поведение. На данный момент я просто не вызываю super и добавляю к моему классу комментарий, указывающий, почему, а не добавляющий дополнительный код. Возможно, потребуется добавить блок в будущих версиях, конечно. :) - person Eric Risler; 25.02.2014