US Visa: Мое первое приложение для iPhone

Почему, собственно?

Имея Mac и iPhone, не попытаться написать мобильное приложение? Как-то неправильно. Благо тут подвернулась задачка, которая прекрасно легла в тему, как весьма полезная и в то же время не очень сложная в реализации. Итак, я погрузился в Objective-C и Cocoa.

Disclaimer

Прошу помнить, что не только мое первое приложение для iOS, но и первое приложение на Objective-C в принципе. Ни разу не претендую ни качество реализации, ни на эффективность, но хочу сказать, что получился весьма целостный несложный пример, который дает представление об Objective-C и разработке под iOS в целом. Особенно для тех, кто вообще этот язык не знает.

Disclaimer 2

Данный пост был изначально опубликован в виде статьи в журнале «The Pragmatic Bookshelf Magazine» на английском языке – US Visa: My First iPhone App. Русская версия, публикуемая здесь, не является точным переводом журнальной версии, так как была написана как отдельный текст несколько позже.

«Хьюстон! У нас проблема!»

За последний год я несколько раз вынужден был подавать на американскую визу в посольстве в Лондоне. Каждый раз мне говорили, что конкретно в моем случае требуется «administrative processing». Документы то у тебя принимают, но потом вместо визы дают номерок (batch number) и говорят периодически заглядывать на их сайт, где есть PDF-ка, в которой по данному номеру следует искать указания, что делать дальше (досылать еще документы, посылать паспорт и т.д.). Нажимаешь на ссылку, открывается файл, жмешь CTRL-F, вводишь номер (batch number) и вперед.

Возникла идея автоматизации – сделать приложение для айфона, в которое может вбить номер заявки один раз, и затем одним нажатием на кнопку получать статус обработки визы. Приложение должно уметь скачивать PDF файл, парсить его и вычленять данные по заявке.

Что делать, если у меня Windows?

Не все еще потеряно. Objective-C можно запустить на Windows через Cygwin или MinGW. Более того, проект GNUstep дает возможность использовать библиотеки AppKit и Foundation для написания графических программ в Windows на Objective-C. Увы, я не буду погружаться столь глубоко в этой статье. Мы сделаем только приложение, работающее в командной строке. Оно будет уметь скачивать PDF и парсить его. Собрать приложение можно будет и на Windows, и на Маке. После, мы практически без изменений будем использовать модули этого приложения для создания полноценной программы для iOS. Но, увы, это уже только для владельцев Маков. Можно, конечно, Хакинтош на виртуалку поставить и гонять приложение на симуляторе айфона в Xcode, но вот загрузить его в реальный айфон вряд ли получится без настоящего Мака.

Установка GNUstep под Windows

Я нашел два великолепныx поста:

  • «Learn Objective-C on Windows» – Как поставить GNUstep и попробовать минимальное приложение.
  • «Clang and Objective-C on Windows» – Как собрать свежий компилятор Clang под Windows. К сожалению, GCC, идущий на данный момент с GNUstep, не поддерживает уровня языка Objective-C, требуемого Apple’ом. К тому же, Apple полностью переключился на Clang с некоторого времени. Так что, надо собирать Clang, так как установщика под Windows у него пока нет. Я просто следовал один в один инструкциям из поста, и все встало без проблем.

Неплохо было бы познакомиться с Objective-C и iOS API

Я про Objective-C не знал ничего, кроме слухов о его необычном подходе к управлению памятью, поэтому пришлось пролистать следующие книжки.

Предупреждение: Ссылки снизу содержат мой личный номер партнерской программы с Амазоном. От возможных покупок, совершенных после перехода по этим ссылкам, я могу получить небольшой процент. Если вас это не устраивает, пожалуйста, не нажимайте на ссылки, или вручную «почистите» URL через cut-paste. Спасибо за понимание.

1. iOS Programming: The Big Nerd Ranch Guide, 3/e (Big Nerd Ranch Guides)

2. Objective-C Programming: The Big Nerd Ranch Guide (Big Nerd Ranch Guides)

3. Programming in Objective-C (4th Edition) (Developer’s Library)

А еще есть один волшебный бесплатный документ – «From C++ to Objective-C».

Итак, задача делится на три основные части:

  • Парсер PDF
  • Скачивалка PDF (желательно ее сделать без привязки к интерфейсу)
  • Интерфейс под iOS

После ознакомления с Objective-C, могу сказать, что для более менее опытного разработчика на C или C++, особенно, если есть опыт разработки UI (я в свое время много возился с Delphi/C++Builder), «въехать» в Objective-C и Cocoa несложно. Достаточно сфокусироваться на весьма необычной полу-ручной модели управления памятью (особенно после RAII в C++ и сборщика мусора в Java). Objective-C сам управляет памятью, но вот контроль за подсчетом ссылок на объекты для их правильного освобождения лежит на вас. Надо понять принцип, иначе утечки памяти неизбежны. У меня именно так и было в начале. Благо отличные инструменты профилировки в Xcode позволяют основные проблемы выявлять практически сразу.

Ниже я приведу несколько личных субъективных впечатлений, как новичка в Objective-C и Cocoa. Вряд ли это будет интересно, если вы уже имеете опыт в них, но вот если нет – думаю, будет интересно.

Для начала интересно посмотреть, как в Objective-C формируются имена функций-членов класса. Это почти как человеческий язык. Если я по-английски скажу «please, find a needle in a portion of some data and add the result to a list implemented as a mutable array», в Objective-C это будет:

+ (bool)findInPortion:(NSMutableData *)someData 
               needle:(NSString*)aNeedle
             andAddTo:(NSMutableArray*)aList {
   ...
}

Если прочитать этот код слева направо сверху вниз, то получается почти полноценное предложение. Формально, полное имя этого метода - findInPortion:needle:andAddTo:. Аргументы именованы, и их имена являются частью полного имени метода. Если правильно давать имена переменных аргументов (someData, aNeedle and aList), то можно фактически писать по-английски. Конечно, это все довольно «многословный» подход, но фантастическая система предсказания в Xcode при наборе кода позволяет быстро и просто набивать все эти обороты. Обратите внимание также, что традиционное выравнивание при разбивке длинных строк происходит по двоеточию, разделяющему формальное имя параметра от переменной, его представляющей.

В Objective-C нетрадиционный синтаксис для вызова методов. Например, вместо:

NSMutableArray* list = NSMutableArray.alloc.init;

пишется:

NSMutableArray* list = [[NSMutableArray alloc] init];    

Выглядит странно, но это вопрос привычки. Опять таки, система предсказания кода при вводе позволяет вводить квадратные скобки даже почти физически не набивая их.

Objective-C и Cocoa используют активно несколько шаблонов программирования, которые просто необходимо освоить. Например, делегаты. Они везде в Cocoa. Делегат – это класс, содержащий в себе обратные вызовы. Вместе передачи пачки отдельных функций или методов, просто передается один объект, реализующий все требуемые обратные вызовы. Например, я использовал стандартный класс NSURLConnection для скачивания PDF’ки. Этот класс требует предоставление ему делегата NSURLConnectionDelegate, методы которого вызываются при различных событиях в процессе скачивания.

Итак, пара недель вечерних бдений за книгами, и я набросал остов моего первого приложения. Но это была только первая часть марлезонского балета. Далее надо было разобраться с форматом PDF.

Парсер PDF

Как уже было сказано, файл, содержащий информацию из посольства, в формате PDF. Описание этого формата доступно на сайте Adobe. Я использовал документ «PDF Reference third edition, Version 1.4».

Разбор PDF у меня реализован весьма кондово. Так как данные приходят порциями, то мы будем анализировать документ по частям, последовательно. Каждую новую порцию данных добавляем в буфер и пытаемся в нем разобрать формат PDF. Сначала ищем фрагменты, обрамленные в маркеры stream и endstream. Содержимое каждого такого блока «разжимаем» через zlib/inflate. После это уже чистый текст, и мы в нем ищем наш batch number, конечно, с учетом языка разметки PDF. Если номер обнаружен, то печатаем его и переходим к следующему блоку.

Основные шаги парсера:

1. Если в данных, принятых на текущий момент, есть блок, ограниченных тегами stream\r\n и endstream\r\n, то вырезаем его из буфера и «разжимаем» через zlib/inflate.

2. Разжатый на первом шаге блок являет текстовым. Нам надо найти в нем фрагменты, обрамленные тегами BT\r\n (Begin Text) и ET\r\n (End Text). Находим все такие блоки и объединяем их в список строк.

3. Внутри каждой строки, найденной на шаге 2, удаляем подстроки, неокруженные круглыми скобками. Все что вокруг круглых скобок – это служебная информация, и она нам не нужна.

4. Итак, мы вычленили чистый текст из PDF’ки. Логически информация в этом файле организована в виде таблицы с тремя колонками: номер заявки (batch number), статус и дата. Увы, среди этого еще попадаются колонтитулы страниц. Чтобы их отсеить, мы будем смотреть, что если текущая строка выглядит как batch number (11 цифр), то за ней обязательно идет строка-статус и строка-даты. Берем их и снова ждем нового batch number’а.

Как я уже сказал, разбор заточен под конкретный файл, и если в посольстве его изменят, то все сломается. Если хотя бы использовать регулярные выражения, то будет гораздо гибче, но я оставлю это читателям на самостоятельную проработку.

ДОПОЛНЕНИЕ. В процессе работы над статьей, появилась идея сделать специальный веб-сервис, обращаясь к которому по простым URL’ам можно получать данные о заявке, а вся «кухня» по разбору PDF’ки происходит «на облаке». В журнале Dr.Dobb’s недавно вышла моя статья - RESTful Web Service in Go Powered by the Google App Engine, описывающая данный подход. Желающие могут «допилить» приложение для работы через этот веб-сервис. Можно вообще сделать хитро: сначала обратиться к веб-сервису, и если от него есть ответ, то на этом закончить, а если нет – запустить процедуру самостоятельного скачивания и разбора PDF’ки.

Приложение для командной строки

Итак, мы знаем почти все, чтобы написать приложение, которое будет скачивать PDF и вычленять из него информацию по нашей заявке. Приложение будет работать из командной строки. Его можно будет собрать из на Маке, и на Windows через GNUstep и Clang. Далее, исходные файлы этого приложения будут использоваться без изменений для версии под iOS.

Файлы:

  • BatchPDFParser.m.h) – PDF-парсер.
  • NSURLConnectionDirectDownload.m.h) – Скачивалка. Тут «обвеска» для NSURLConnection (инициализация, делегаты, цикл обработки событий).
  • DirectDownloadDelegate.m.h) – Делегат для NSURLConnection, принимающий вызовы в различные моменты скачивания.
  • ViewController.m – прототип ViewController. Это «прослойка» между скачивалкой и будущим графическим интерфейсом. В OSX и iOS используется концепция MVC (Model-View-Controller). «Контроллер» обеспечивает связь между элементами интерфейса и бизнес-логикой приложения. Текущий контроллер в основном содержит заглушки, которые будут реализованы в полной графической версии.
  • main-cli.m – Точка входа.

BatchPDFParser.h

Этот файл содержит объявление класса Batch, содержащего информацию об обновлении статуса заявки, и класса BatchPDFParser, который реализует метод findInPortion:needle:andAddTo: (кстати, это статический метод класса, видите + начале строки?).

@interface Batch: NSObject {
  NSString *batchNumber, *status, *date;
}
@property (atomic, copy) NSString* batchNumber, *status, *date;
@end

@interface BatchPDFParser: NSObject

+ (bool)findInPortion:(NSMutableData *)data needle:(NSString* const)needle andAddTo:(NSMutableArray*)list;

@end

BatchPDFParser.m

В этом файле реализация парсера PDF.

#import <Foundation/Foundation.h>
#import "BatchPDFParser.h"
#import "zlib.h"

@implementation Batch
@synthesize batchNumber, status, date;

- (void) dealloc {
    [batchNumber release];
    [status release];
    [date release];
    [super dealloc];
}
@end

@implementation BatchPDFParser

Метод findInData:fromOffset:needle: ищет подстроку в данном блоке данных (типа strstr()). Поиск примитивный, и его можно ускорить, например, реализовав алгоритм КМП.

+ (int) findInData:(NSMutableData *)data fromOffset:(size_t)offset needle:(char const * const)needle {
    int const needleSize = strlen(needle);
    char const* const bytes = [data mutableBytes];
    int const bytesLength = [data length] - needleSize;
    for (int i = 0; i < bytesLength;) {
        char const* const current = memchr(bytes + i, needle[0], bytesLength - i);
        if (current == NULL) return -1;
        if (memcmp(current, needle, needleSize) == 0) return current - bytes;
        i = current - bytes + 1;
    }
    return -1;
}

Метод isBatchNumber:number: проверяет, является ли строка номером заявки (batch number):

+ (bool) isBatchNumber:(NSString*)number {
    long long const value = [number longLongValue];
    return value >= 20000000000L && value < 29000000000L;
}

Метод findBatchNumberInChunk:needle:andAddTo: ищет фрагменты, обрамленные тегами BT и ET. В них выделяет текст в круглых скобках, и уже среди найденного выделяет конкретно номер заявки, строку-статус и строку-дату.

+ (bool) findBatchNumberInChunk:(char const*)chunk needle:(NSString*)needle andAddTo:(NSMutableArray*)list {
    enum {
        waitBT, waitText, insideText
    } state = waitBT;
    enum {
        waitBatchNumber, waitStatus, waitDate
    } batchParserState = waitBatchNumber;
    NSMutableString* line = [[NSMutableString alloc] init];
    Batch* batch = nil;
    bool found = NO;
    while (*chunk) {
        if (state == waitBT) {
            if (chunk[0] == 'B' && chunk[1] == 'T') {
                state = waitText;
                [line deleteCharactersInRange:NSMakeRange(0, [line length])];
            }
        } else if (state == waitText) {
            if (chunk[0] == '(') {
                state = insideText;
            } else if (chunk[0] == 'E' && chunk[1] == 'T') {
                if (batchParserState == waitBatchNumber) {
                    if ([self isBatchNumber:line]) {
                        [batch autorelease];
                        batch = [[Batch alloc] init];
                        batch.batchNumber = line;
                        batchParserState = waitStatus;
                    }
                } else if (batchParserState == waitStatus) {
                    batch.status = line;
                    batchParserState = waitDate;
                } else if (batchParserState == waitDate) {
                    batch.date = line;
                    batchParserState = waitBatchNumber;
                    if ([batch.batchNumber isEqualToString:needle]) {
                        NSString* pair = [NSString stringWithFormat:@"%@\n%@", batch.status, batch.date];
                        [list addObject:pair];
                        NSLog(@"Found match: '%@' '%@' '%@'", batch.batchNumber, batch.status, batch.date);
                        found = YES;
                    }
                }
                [line autorelease];
                line = [[NSMutableString alloc] init];
                state = waitBT;
            }
        } else if (state == insideText) {
            if (chunk[0] == ')') {
                state = waitText;
            } else {
                char const c[2] = { chunk[0], 0 };
                [line appendString:[NSString stringWithUTF8String:&c[0]]];
            }
        }
        chunk += 1;
    }
    [line release];
    [batch release];
    return found;
}

Теперь главный метод findInPortion:needle:andAddTo:. Тут выделяются куски, обрамленные тегами stream\r\n и endstream\r\n, содержимое разжимается через zlib/inflate и передается в findBatchNumberInChunk:needle:andAddTo: на анализ.

+ (bool)findInPortion:(NSMutableData *)portion needle:(NSString*)needle andAddTo:(NSMutableArray*)list {
    static char const* const streamStartMarker = "stream\x0d\x0a";
    static char const* const streamStopMarker = "endstream\x0d\x0a";
    bool found = false;
    while (true) {
        int const beginPosition = [self findInData:portion fromOffset:0 needle:streamStartMarker];
        if (beginPosition == -1) break;
        int const endPosition = [self findInData:portion fromOffset:beginPosition needle:streamStopMarker];
        if (endPosition == -1) break;
        int const blockLength = endPosition + strlen(streamStopMarker) - beginPosition;

        char const* const zipped = [portion mutableBytes] + beginPosition + strlen(streamStartMarker);
        z_stream zstream;
        memset(&zstream, 0, sizeof(zstream));
        int const zippedLength = blockLength - strlen(streamStartMarker) - strlen(streamStopMarker);

        zstream.avail_in = zippedLength;
        zstream.avail_out = zstream.avail_in * 10;
        zstream.next_in = (Bytef*)zipped;
        char* const unzipped = malloc(zstream.avail_out);
        zstream.next_out = (Bytef*)unzipped;
        int const zstatus = inflateInit(&zstream);
        if (zstatus == Z_OK) {
            int const inflateStatus = inflate(&zstream, Z_FINISH);
            if (inflateStatus >= 0) {
                found = found || [BatchPDFParser findBatchNumberInChunk:unzipped needle:needle andAddTo:list];
            } else {
                NSLog(@"inflate() failed, error %d", inflateStatus);
            }
        } else {
            NSLog(@"Unable to initialize zlib, error %d", zstatus);
        }
        free(unzipped);
        inflateEnd(&zstream);

        int const cutLength = endPosition + strlen(streamStopMarker);
        [portion replaceBytesInRange:NSMakeRange(0, cutLength) withBytes:NULL length:0];
    }
    return found;
}

@end

DirectDownloadViewDelegate.h

Заголовок делегата для NSURLConnectionDelegate:

@protocol DirectDownloadViewDelegate<NSObject>

- (void)setProgress: (float)progress;
- (void)appendStatus: (NSString*)status;
- (void)setCompleteDate: (NSString*)date;

@end

DirectDownloadDelegate.h

Собственно, сам делегат NSURLConnectionDelegate.

#import "DirectDownloadViewDelegate.h"

@interface DirectDownloadDelegate : NSObject {
    NSError *error;
    BOOL done;
    BOOL found;
    NSMutableData *receivedData;
    float expectedBytes, receivedBytes;
    id<DirectDownloadViewDelegate> viewDelegate;
    NSString* needle;
}

- (id) initWithNeedle:(NSString*)aNeedle andViewDelegate:(id<DirectDownloadViewDelegate>)aViewDelegate;

@property (atomic, readonly, getter=isDone) BOOL done;
@property (atomic, readonly, getter=isFound) BOOL found;
@property (atomic, readonly) NSError *error;

@end

DirectDownloadDelegate.m

И его реализация:

#import <Foundation/Foundation.h>

#import "DirectDownloadDelegate.h"
#import "BatchPDFParser.h"

@implementation DirectDownloadDelegate
@synthesize error, done, found;

Конструктор initWithNeedle:andViewDelegate: создает делегата и параметризирует его другим делегатом, DirectDownloadViewDelegate, который будет использоваться для задачи обновления экрана. Тут, кстати, мы впервые видит и деструктор, (void) dealloc:.

- (id) initWithNeedle:(NSString*)aNeedle andViewDelegate:(id<DirectDownloadViewDelegate>)aViewDelegate {
    viewDelegate = aViewDelegate;
    [viewDelegate retain];

    needle = [[NSString alloc] initWithString:aNeedle];
    receivedData = [[NSMutableData alloc] init];
    expectedBytes = receivedBytes = 0.0;
    found = NO;

    return self;
}

- (void) dealloc {
    [error release];
    [receivedData release];
    [needle release];
    [viewDelegate release];
    [super dealloc];
}

Метод connectionDidFinishLoading: вызывается, когда соединение закончено.

- (void) connectionDidFinishLoading:(NSURLConnection *)connection {
    done = YES;
    NSLog(@"Connection finished");
}

Метод connection:didFailWithError: вызывает при ошибке при скачивании файла.

- (void) connection:(NSURLConnection *)connection didFailWithError:(NSError *)anError {
    error = [anError retain];
    [self connectionDidFinishLoading:connection];
}

Метод connection:didReceiveData: вызывается, когда получена новая порция данных из канала. Каждую такую порцию мы добавляем в буфер, обновляем индикатор прогресса скачивания (через еще один делегат, viewDelegate), затем пробуем вычленить фрагменты данных по PDF формату, и, наконец, печатаем то, что было найдено.

- (void) connection:(NSURLConnection *)connection didReceiveData:(NSData *)someData {
    receivedBytes += [someData length];
    [viewDelegate setProgress:(receivedBytes / expectedBytes)];
    [receivedData appendData:someData];

    NSMutableArray* list = [[NSMutableArray alloc] init];
    bool foundInCurrentPortion = [BatchPDFParser findInPortion:receivedData needle:needle andAddTo:list];
    for (id batch in list) {
        NSLog(@"[%@]", [batch stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"]);
        [viewDelegate appendStatus:batch];
    }
    [list release];
    found = found || foundInCurrentPortion;
}

Последний обратный вызов делегата NSURLConnectionDelegate, что мы используем, называется connection:didReceiveResponse:. Он вызывается, когда получен информационный ответ по HTTP, содержащий заголовки. Мы из заголовка «Content-Length» берем длину будущего файла, чтобы позже сообразно обновлять индикатор скачивания.

- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSHTTPURLResponse *)someResponse {
    NSDictionary *headers = [someResponse allHeaderFields];
    NSLog(@"[didReceiveResponse] response headers: %@", headers);
    if (headers) {
        if ([headers objectForKey: @"Content-Length"]) {
            NSLog(@"Content-Length: %@", [headers objectForKey: @"Content-Length"]);
            expectedBytes = [[headers objectForKey: @"Content-Length"] floatValue];
        } else {
            NSLog(@"No Content-Length header found");
        }
    }
}

NSURLConnectionDirectDownload.h

В этом файле находится метод donwloadAtURL:searching:viewingOn:, который мы добавляем к классу NSURLConnection. Интересно тут то, что через понятие категорий в Objective-C можно «примешивать» новые методы к существующим классам. Тут мы к классу NSURLConnection добавляем категорию DirectDownload.

@interface NSURLConnection (DirectDownload)

+ (BOOL) downloadAtURL:(NSURL *)url searching:(NSString*)batchNumber viewingOn:(id)viewDelegate;

@end

NSURLConnectionDirectDownload.m

Ну и финальная часть скачивалки PDF. Метод donwloadAtURL:searching:viewingOn: создает соединение и запускает скачивание. Затем происходит ожидание в цикле NSRunLoop, пока скачивание не закончится. Этот цикл позволяет приложению реагировать на события в процессе скачивания. Обратите внимание, это до сих пор скачивалка ни как не привязана к графическому интерфейсу. Она использует делегат viewDelegate для общения с «мордой» приложения.

#import <Foundation/Foundation.h>
#import "DirectDownloadDelegate.h"

@implementation NSURLConnection (DirectDownload)

+ (BOOL) downloadAtURL:(NSURL *)url searching:(NSString*)batchNumber viewingOn:(id)viewDelegate {
    NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:url];

    DirectDownloadDelegate *delegate = [[[DirectDownloadDelegate alloc] initWithNeedle:batchNumber andViewDelegate:viewDelegate] autorelease];
    NSURLConnection *connection = [[NSURLConnection alloc] initWithRequest:request delegate:delegate];
    [request release];

    while ([delegate isDone] == NO) {
        [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1.0]];
    }

    if ([delegate isFound] != YES) {
        [viewDelegate appendStatus:@"This batch number is not found."];
        NSLog(@"This batch number is not found.");
    }

    NSLog(@"PDF is processed");
    [connection release];

    NSDateFormatter* dateFormatter = [[NSDateFormatter alloc] init];
    dateFormatter.dateFormat = @"yyyy/MM/dd HH:mm:ss";
    NSString* lastUpdateDate = [dateFormatter stringFromDate:[NSDate date]];
    NSLog(@"Last update at: %@", lastUpdateDate);
    [viewDelegate setCompleteDate:lastUpdateDate];
    [dateFormatter release];

    NSError *error = [delegate error];
    if (error != nil) {
        NSLog(@"Download error: %@", error);
        return NO;
    }

    return YES;
}

@end

ViewController.m

Как уже было сказано, в приложении для командной строки контроллер будет содержать только заглушки, которые мы реализуем позже в полной версии программы.

#import <Foundation/Foundation.h>

#import "DirectDownloadViewDelegate.h"

#define IBAction void

Пустой класс-заглушка ViewController.

@interface ViewController : NSObject <DirectDownloadViewDelegate>
@end

#import "NSURLConnectionDirectDownload.h"

Адрес, откуда качать файл.

static char const* const pdf = "http://photos.state.gov/libraries/unitedkingdom/164203/cons-visa/admin_processing_dates.pdf";

И mock-реализация класса-контроллера.

@implementation ViewController

Тестовый обратный вызов appendStatus: вызывается, когда обнаружено очередное обновление по заявке. Тут мы просто логируем, а в полном приложении будем обновлять экранную форму.

- (void) appendStatus:(NSString*)status {
    NSLog(@"appendStatus(): '%@'", [status stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"]);
    // Some code is skipped here because not required for the command line mode.
}

Тестовый обратный вызов setProgress: вызывается, когда после принятия очередной порции данных надо обновить индикатор скачивания.

- (void) setProgress:(float)progress {
    // Some code is skipped here because not required for the command line mode.
}

Тестовый обратный вызов setCompleteDate: вызывается, когда анализ PDF полностью закончен. Тут, опять, мы просто логируем.

- (void) setCompleteDate:(NSString*)date {
    NSLog(@"setCompleteDate(): '%@'", date);
    // Some code is skipped here because not required for the command line mode.
}

Ну и финальный метод, который все запускает, updateBatchStatus:. В полной программе он будет вызываться при нажатии кнопки на форме. Тут он вызывается из main().

- (bool) updateBatchStatus:(NSString*)batchNumber {
    NSURL *url = [[[NSURL alloc] initWithString:[NSString stringWithCString:pdf encoding:NSASCIIStringEncoding]] autorelease];
    return [NSURLConnection downloadAtURL:url searching:batchNumber viewingOn:self];
}

@end

main-cli.m

Запуск из командной строки.

#import <Foundation/Foundation.h>
#import "DirectDownloadDelegate.h"

@interface ViewController : NSObject <DirectDownloadViewDelegate>
- (bool) updateBatchStatus:(NSString*)batchNumber;
@end

int main(int argc, char *argv[]) {
    @autoreleasepool {
        ViewController* viewController = [ViewController alloc];
        [viewController updateBatchStatus:[NSString stringWithCString:argv[1] encoding:NSASCIIStringEncoding]];
        [viewController release];
    }
    return 0;
}

Попробуем все это собрать и запустить?

Makefile для Мак:

files = \
    ViewController.m \
    BatchPDFParser.m \
    NSURLConnectionDirectDownload.m \
    DirectDownloadDelegate.m
    main-cli.m

all: build run

build:
    clang -o USVisaTest -DTESTING -framework Foundation -lz $(files)

run:
    ./USVisaTest 20121456171

Makefile GNUmakefile для GNUstep:

include $(GNUSTEP_MAKEFILES)/common.make

TOOL_NAME = USVisa
USVisa_OBJC_FILES = \
    ../ViewController.m \
    ../BatchPDFParser.m \
    ../NSURLConnectionDirectDownload.m \
    ../DirectDownloadDelegate.m \
    ../main-cli.m
USVisa_TOOL_LIBS = -lz
ADDITIONAL_OBJCFLAGS = -DTESTING
CC = clang

include $(GNUSTEP_MAKEFILES)/tool.make

run:
    ./obj/USVisa 20121456171

Набираем make. Windows:

This is gnustep-make 2.6.2. Type 'mmake print-gnustep-make-help' for help.
Making all for tool USVisa...
 Creating obj/USVisa.obj/../...
 Compiling file ViewController.m ...
 Compiling file BatchPDFParser.m ...
 Compiling file NSURLConnectionDirectDownload.m ...
 Compiling file DirectDownloadDelegate.m ...
 Compiling file main-cli.m ...
 Linking tool USVisa ...

Можно запустить проверить реальную заявку:

make run

У меня вывелось следующее:

This is gnustep-make 2.6.2. Type 'mmake print-gnustep-make-help' for help.
./obj/USVisa 20121456171
2012-06-19 17:27:11.472 USVisa[3420] [didReceiveResponse] response headers: {"Accept-Ranges" = bytes; "Cache-Control" = "max-age=600"; Connection = "keep-alive"; "Content-Length" = 2237242; "Content-Type" = "application/pdf"; Date = "Tue, 19 Jun 2012 16:27:11 GMT"; ETag = "\"4b2ca3e41de5ba4ae45670e776edfc3b:1339778351\""; "Last-Modified" = "Fri, 15 Jun 2012 16:06:15 GMT"; Server = Apache; }
2012-06-19 17:27:11.604 USVisa[3420] Content-Length: 2237242
2012-06-19 17:27:12.093 USVisa[3420] Found match: '20121456171' 'send passport & new travel itinerary' '14-Jun-12'
2012-06-19 17:27:12.104 USVisa[3420] [send passport & new travel itinerary\n14-Jun-12]
2012-06-19 17:27:12.111 USVisa[3420] appendStatus(): 'send passport & new travel itinerary\n14-Jun-12'
2012-06-19 17:27:13.769 USVisa[3420] Connection finished
2012-06-19 17:27:13.774 USVisa[3420] PDF is processed
2012-06-19 17:27:13.961 USVisa[3420] Last update at: 2012/06/19 16:27:13
2012-06-19 17:27:13.972 USVisa[3420] setCompleteDate(): '2012/06/19 16:27:13'

Итак, все работает: скачивалка и парсер PDF. Теперь займемся версией под iOS. Увы, только для пользователей Mac.

Макет экранной формы

Я сделал приложение крайне простым: одна форма с полем ввода, кнопкой и местом для вывода обновлений.

Индикатор скачивания и крутящийся бегунок появляются временно.

ViewController.h

Вот сейчас это полная реализации контроллера. Через макрос TESTING я сделал разделение между упрощенной и полной версией.

#import <Foundation/Foundation.h>
#import "DirectDownloadViewDelegate.h"

#ifdef TESTING
#define IBAction void
@interface ViewController : NSObject <DirectDownloadViewDelegate>
@end
#else
#import "ViewController.h"
#endif

#import "NSURLConnectionDirectDownload.h"

static char const* const pdf = "http://photos.state.gov/libraries/unitedkingdom/164203/cons-visa/admin_processing_dates.pdf";

@implementation ViewController

#ifndef TESTING
@synthesize updateProgressView, batchNumberTextField, statusTextView, lastUpdatedLabel, updateButton;
#endif

NSString* const PropertiesFilename = @"Properties";

NSString *pathInDocumentDirectory(NSString *fileName) {
    NSArray *documentDirectories = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
    NSString *documentDirectory = [documentDirectories objectAtIndex:0];
    return [documentDirectory stringByAppendingPathComponent:fileName];
}

Сейчас обратный вызов appendStatus: не только логирует, но и обновляет экранную форму.

- (void) appendStatus:(NSString*)status {
    NSLog(@"appendStatus(): '%@'", [status stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"]);
#ifndef TESTING
    if ([[statusTextView text] length] == 0)
        [statusTextView setText:@"Status:\n"];
    [statusTextView setText:[[statusTextView text] stringByAppendingString:status]];
    [statusTextView setText:[[statusTextView text] stringByAppendingString:@"\n"]];
#endif
}

setProcess: обновляет индикатор скачивания.

- (void) setProgress:(float)progress {
#ifndef TESTING
    updateProgressView.progress = progress;
#endif
}

setCompleteDate: выводит дату обновления в текстовое поле на экране.

- (void) setCompleteDate:(NSString*)date {
    NSLog(@"setCompleteDate(): '%@'", date);
#ifndef TESTING
    [lastUpdatedLabel setText:date];
#endif
}

- (bool) updateBatchStatus:(NSString*)batchNumber {
    NSURL *url = [[[NSURL alloc] initWithString:[NSString stringWithCString:pdf encoding:NSASCIIStringEncoding]] autorelease];
    return [NSURLConnection downloadAtURL:url searching:batchNumber viewingOn:self];
}

Теперь несколько вызовов, специфичных для iOS. Метод viewDidLoad: вызывается системой, когда экранная форма загружена и готова к использованию. Тут мы вручную создаем крутящийся бегунок и подправляем высоты двух элементов, кнопки и поля ввода, так как почему-то Xcode Interface Builder не позволяет менять их при дизайне формы.

#ifndef TESTING
- (void)viewDidLoad
{
    [super viewDidLoad];
    // Do any additional setup after loading the view, typically from a nib.
    spinnerActivityIndicatorView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge];
    [spinnerActivityIndicatorView setColor:[UIColor blueColor]];
    CGSize size = [[self view] frame].size;
    [spinnerActivityIndicatorView setCenter:CGPointMake(size.width / 2, size.height / 2 + 60)];
    [self.view addSubview:spinnerActivityIndicatorView];

    CGRect rect = [self.updateButton bounds];
    rect.size.height += 10;
    [self.updateButton setBounds:rect];

    rect = [self.batchNumberTextField bounds];
    rect.size.height += 20;
    [self.batchNumberTextField setBounds:rect];

#ifdef DEBUG
    NSLog(@"DEBUG mode");
#endif
}

viewDidUnload вызывается, когда форма становится неактивной.

- (void)viewDidUnload
{
    [super viewDidUnload];
    // Release any retained subviews of the main view.
}

Метод shouldAutorotateToInterfaceOrientation: позволяет контролировать поведение для смене ориентации аппарата. Тут мы разрешаем только портретное положение, не вверх ногами.

- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation
{
    return (interfaceOrientation == UIInterfaceOrientationPortrait);
}
#endif

Метод launchUpdate: вызывает при нажатии на кнопку Update на форме. Мы блокируем кнопку от повторного нажатия, вывод индикатор скачивания и крутящийся бегунок.

- (IBAction)launchUpdate:(id)sender {
    [self setProgress:0.0];
#ifndef TESTING
    [updateButton setEnabled: NO];
    [updateProgressView setHidden:NO];

    NSString* previousStatus = [statusTextView text];
    [statusTextView setText:@""];

    NSString* batchNumber = [batchNumberTextField text];

    [spinnerActivityIndicatorView startAnimating];
    BOOL const ok = [self updateBatchStatus:batchNumber];
    [spinnerActivityIndicatorView stopAnimating];

    if (!ok) {
        UIAlertView *alert = 
            [[UIAlertView alloc] initWithTitle:@"Error"
                                       message:@"Internet connectivity problem"
                                      delegate:self cancelButtonTitle:nil
                             otherButtonTitles:@"OK", nil];
        [alert show];
        [alert release];
        [statusTextView setText:previousStatus];
    }

    [updateProgressView setHidden:YES];
    [updateButton setEnabled: YES];
#endif
}

Методы saveProperties: и loadProperties: сохраняют и восстанавливают содержимое формы при запуске и остановке приложения. Обратите внимание, что для сохранения данных в файле надо запросить у системы положение предназначенного для этого каталога.

- (void) saveProperties {
    NSDictionary *props = [[NSDictionary alloc] initWithObjectsAndKeys:
#ifndef TESTING
                          batchNumberTextField.text, @"batchNumberTextField",
                          statusTextView.text, @"statusTextView",
                          lastUpdatedLabel.text, @"lastUpdatedLabel",
#endif
                           nil];
    for (NSString* key in props) {
        NSLog(@"%@ - %@", key, [props objectForKey:key]);
    }

    NSString* filename = pathInDocumentDirectory(PropertiesFilename);
    if ([props writeToFile:filename atomically:YES] == NO)
        NSLog(@"Unable to save properties into file [%@]", filename);

    [props release];
}

- (void) loadProperties {
    NSDictionary *props = [[NSDictionary alloc] initWithContentsOfFile:pathInDocumentDirectory(PropertiesFilename)];
    for (NSString* key in props) {
        NSLog(@"%@ - %@", key, [props objectForKey:key]);
    }

#ifndef TESTING
    [batchNumberTextField setText:[props objectForKey:@"batchNumberTextField"]];
    [statusTextView setText:[props objectForKey:@"statusTextView"]];
    [lastUpdatedLabel setText:[props objectForKey:@"lastUpdatedLabel"]];
#endif
    [props release];
}

- (IBAction)textFieldReturn:(id)sender {
#ifndef TESTING
    [sender resignFirstResponder];
#endif
}

-(IBAction)backgroundTouched:(id)sender {
#ifndef TESTING
    [batchNumberTextField resignFirstResponder];
#endif
}

@end

Все! Мы рассмотрели все основные файлы. Приложение полностью готово. Можно собирать и заливать в аппарат (не забыв купить у Apple лицензию разработчика).

Я выложил полный проект на GitHub – usvisa-app. Замечания и мысли принимаются.

Можно заценить видео:

https://www.youtube.com/watch?v=fxKlXDsDANU

И еще!

Если вы подумываете о том, чтобы ваше приложение было распродано миллионным тиражом, стоит начать с красивой иконки. Для приложения обычно надо их несколько: 57x57 и 114x114 для непосредственно приложения, и 512x512 и 1024x1024 для публикации в AppStore.

Мы поступим проще и возьмем иконку из открытых источников – The Great Seal of the United States.

P.S.

Я решил написать пост про это приложение после того, как цензоры AppStore его «завернули», сославшись на пункт в правилах, где говорится, что приложения с минимальной функциональной нагрузкой, которые можно реализовать через HTML5, не будут допущены. Видимо, они более не хотят видеть пукающих или просто отображающих статическую картинку приложений. Можно было бы поспорить с цензорами на тему минимальной функциональной нагрузки или реализации через HTML5, но я забил. Во-первых, лично мне нравится, что Apple старается не пропускать бесполезные и некачественные приложения, и во-вторых – я и так получил массу удовольствия от освоения Objective-C, и на данный момент работаю еще над двумя приложениями.

P.P.S.

Скоро будет еще статейка про разработку приложений для iOS для новичков, так что следите за анонсами.


Disclaimer

Комментарии