Newsstand app. Создание iOS журнала

в 9:42, , рубрики: iOS, newsstand, разработка под iOS, метки: ,

Последнее время я занимался разработкой iOS версии одного бумажного журнала. Собственно, это и есть попытка раскрыть сию тему.

Начну со вступления. Что же такое Newsstand? Откуда возникла такая сущность и во что она превратилась? Размышляя, пришел к следующему: это версия журнала, обернутая в iOS программу, отличается от pdf просто невменяемым размером. Одна из причин — огромная куча картинок. Однако, эта куча и создает глянец iOS журнала. Причиной же появления идеи Newsstand, я так понимаю, была позиция Apple, относительно прав собственности на некий контент. Т.е. была задача честно (часто платно) распространять периодику, да так, чтоб ее было трудно копипастить. Эти ребята с задачей справились — полагаю нету электронного издательства с большим денежным оборотом, чем Newsstand (если так можно сказать).

Ознакомившись, на уровне пользователя, с несколькими журналами, настало время искать техническое решение. Первым, всплыл Newsstand Kit.

Newsstand Kit состоит из следующих штуковин: особый механизм управления библиотекой (NKLibrary) отдельных выпусков журнала (NKIssue) и их загрузка (NKAssetDownload), включающая бэкграунд загрузку. Ну клево! Однако, на тот момент мне было совершенно неясно как превратить эти штуковины в журнал.

Все же. Очевидно, что программка-журнал состоит из двух частей: менеджмент выпусков журнала и отображение отдельного журнала. Т.е. Newsstand Kit ориентирован на первую составляющую — менеджмент. Вторую составляющую — отображение одного выпуска журнала — Apple никак не обременила какими-то шаблонами. Начну с нее, второй.

Теоретически, мы имеем всю необходимую информацию, скачанную с нашего сервера, и обязаны ее отобразить. Т.е. речь пошла о ''Приложении-брошюре''. Скачанную информацию можно, условно, разделить на сырцы (изображения, фильмы, тексты, html-ины, pdf-ы) и конфиг, по которому мы сможем все это собрать воедино. Предполагаю, что не всем будет интересно читать про ''Брошюрку'', потому отправляю таких далее — к части о менеджменте.

Как я упоминал выше, Apple мало ограничивает разработчиков в части отображения конкретного журнала. Однако, наверное, принято хорошим тоном, листание журнала свайпом. Для этого я использовал UIScrollView и идею бесконечного скролла (dev apple -> WWDC -> ScrollView Techniques -> Infinite scrolling). Если в двух словах, то на всю главную View нужно положить UIScrollView. У UIScrollView установить contentSize в три экрана по ширине, и положить на нее, последовательно три UIView (каждая, размером с один экран). Там еще пара настроек и мы имеем красивое листание по свайпу. На финише остается реализовать метод делегата у UIScrollView — scrollViewDidEndDecelerating: — и, по нему, обновлять внутренние UIView. Усложнение бесконечного скролла — если вложить в видимую UIVeiw еще один, аналогичный, UIScrollView — приводит к горизонтальному и вертикальному перелистыванию.

ОК. Остается вопрос: а где же брать нужные в данный момент UIView?
Начнем с ''данного момента''. Можно сказать, что его характеристикой является номер открытой страницы журнала. Выходит что нам, в приложении, нужна штуковина, которая будет хранить индекс текущей страницы. Такая штуковина нужна одна на все приложение, и к ней должен быть простой доступ — нам нужен синглтон. Имея индекс, вопрос сводится к новому: где взять UIView адекватный индексу? Наверное, конфиг его знает. Ну и далее: а почему бы не попросить наш синглтон прочитать конфиг и, зная индекс, выдать нужную вьюшку? Приведу интерфейс синглтона и перейдем к конфигу

@interface DataManager : NSObject

@property (nonatomic) int articleIndex;
@property (nonatomic) int pageIndex;
@property (nonatomic, strong) NSArray *articles;

+ (id) sharedManager;

- (UIView *) currentView;
- (UIView *) prevView;
- (UIView *) nextView;
- (UIView *) upperView;
- (UIView *) lowerView;

- (int) lastArticleIndex;
- (int) lastPageIndex;

@end

Конфигом в Objective-C принято или удобно видеть .plist файл. Собственно, нужно собрать plist, который будет отображать страницы журнала, какие-то настройки этих страниц и линки на сырцы. Полагаю, на примерах будет понятнее

<plist version="1.0">
<array>
	<array>
		<dict>
			<key>class</key>
			<string>SimplePage</string>
			<key>properties</key>
			<dict>
				<key>baseImage</key>
				<string>1-0.png</string>
			</dict>
		</dict>
	</array>
	<array>
	<array>
	<array>

		<dict>
		<dict>
			<key>class</key>
			<string>PhotoPage</string>
			<key>properties</key>
			<dict>
				<key>baseImage</key>
				<string>9-0-4.png</string>
				<key>photos</key>
				<array>
					<dict>
						<key>photo</key>
						<string>9-1-f1.png</string>
						<key>thumbnail</key>
						<string>9-1-f1m.png</string>
					</dict>
					<dict>
					<dict>
					<dict>

Полагаю, что вы заметили первое свойство каждой страницы — class. Ага, как удобно! Написал в конфиге имя класса, и по нему создал нужную страничку. Фрагмент вышеупомянутого синглтона

- (UIView *) currentView {
    NSArray *article = [self.articles objectAtIndex:articleIndex];
    NSDictionary *page = [article objectAtIndex:pageIndex];
    return [self viewWithDictionary:page];
}

- (UIView *) viewWithDictionary: (NSDictionary *) dictionary {
    Class class = NSClassFromString([dictionary valueForKey:@"class"]);
    NSDictionary *pageProperties = [dictionary valueForKey:@"properties"];
    UIView *uiView = [[class alloc] initWithDictionary:pageProperties];
    return uiView;
}

Что осталось упомянуть? Покажу интерфейс SimplePage. Остальные классы страниц, так или иначе, наследуют его

@interface SimplePage : UIView

@property (nonatomic, strong) NSString *imageDirectory;

- (id) initWithDictionary: (NSDictionary *) pageProperties;

@end

Т.е. мы создаем набор классов, которые наследуют UIView, и согласно этим классам пишем конфиг одного журнала.

Вот еще всплыла переменная imageDirectory. Тут дело в том, что позднее нам нужно будет собрать картинки брошюры, положить к ним plist и отправить это добро жить на сервер. Посему, вместо добавления картинок в проект, я их собирал в папочке симулятора — DocumentsDirectory. А вот ее инициализация

#if IS_LOCAL
    // doc/img dir
    imageDirectory = [DocumentsDirectory stringByAppendingPathComponent:@"img"];
#else
    // issue dir
    NKIssue *nkCurrentlyReadingIssue = [[NKLibrary sharedLibrary] currentlyReadingIssue];
    imageDirectory = [nkCurrentlyReadingIssue.contentURL path];
#endif
    
#if DEBUG
    NSLog(@"imageDirectory %@", imageDirectory);
#endif

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

NK Менеджмент

Само слово предполагает, что менеджерить нужно что-то. Коллекцию чего-то, скачанную с сервера. Так мы приходим к еще одной структуре. Чтоб ее понять, нужно поверхностно рассмотреть NKLibrary и NKIssue.

По простому, NKLibrary это коллекция для NKIssue-s. NKLibrary имеет ряд полезностей, т.е. это коллекция, заточенная под Newsstand. Еще это синглтон и, пожалуй, на данном этапе, все.

NKIssue хранит информацию про один журнал. Минимально, issue, обязана иметь имя и дату. Если я не ошибаюсь, имя будет ключом, а по дате будет сортировка.

Итак, что-то, где-то подсмотрев, мы имеем issues.plist

<plist version="1.0">
<array>
	<dict>
		<key>Name</key>
		<string>f-2</string>
		<key>Title</key>
		<string>лето/осень 2013</string>
		<key>Date</key>
		<date>2013-11-28T08:00:00Z</date>
		<key>Cover</key>
		<string>http://fo-nt.net/f/f2.png</string>
		<key>Content</key>
		<string>http://fo-nt.net/f/f2.zip</string>
	</dict>
	<dict>
	<dict>

Качаем plist с сервера, перебираем его, создаем NKIssue и добавляем их в NKLibrary

NKLibrary *nkLib = [NKLibrary sharedLibrary];
issuesDictionary = [NSArray arrayWithContentsOfFile:issuesPlistFilePath];
for (NSDictionary *issueDictionary in issuesDictionary) {
	NSString *name = [issueDictionary valueForKey:@"Name"];
	NKIssue *nkIssue = [nkLib issueWithName:name];
	if(!nkIssue) {
		[nkLib addIssueWithName:name date:[issueDictionary objectForKey:@"Date"]];
		
		NSString *coverPath = [issueDictionary valueForKey:@"Cover"];
		if (IS_RETINA) coverPath = [self retinaURLStringForString:coverPath];
		NSString *coverName = [coverPath lastPathComponent];

Поясню. Естественно, что тут мы уже импортировали NewsstandKit. Иначе, как мы могли знать про NKLibrary и NKIssue? С легкостью в строчку, получаем экземпляр NKLibrary — nkLib. Перебирая массив, мы просим у nkLib дать нам конкретный журнал, по его имени. Если библиотека скажет ''фиг Вам'' — станет понятно, что в конфиге журнал есть, а в библиотеке — нету -> нужно добавить.

На скриншоте, еще есть строчка 'if (IS_RETINA)'. Кратенько, тут дело в том, что все картинки журнала находятся на сервере. Тут мы уже знаем, какой у нас дисплей. Ну и зачем же качать картинки для чужого дисплея. Забегая вперед, скажу, что комплект закачки, для каждого журнала, Apple рекомендует оформить в виде архива. На финише, логично, сделать два архива на один журнал: простой и @2x.

Есть у нас актуальная NKLibrary. Скромно, но уже можно отобразить UI имеющихся журналов.

Визуализация отображения библиотеки для каждого журнала своя. Однако, есть что-то устоявшееся. Есть NKIssue с набором свойств — отображаем их. Среди них, особенно интересно, свойство ''статус'', которое может быть: none, downloading и available. Это я веду к тому, что отображенный журнал можно загружать, ожидать окончания загрузки и читать, соответственно.

Загружать нужно комплект, который был подготовлен на этапе создания брошюры. NKAssetDownload это третья NK штуковина — специализированный загрузчик для Newsstand (наверное так). Процедура: с помощью NKIssue и NSURLRequest (полученного из NSURL, который, в свою очередь получен из строки URL для выбраного журнала), создаем экземпляр NKAssetDownload. У него нужно вызвать метод downloadWithDelegate:(id <NSURLConnectionDownloadDelegate>)delegate

NSURLRequest *req = [NSURLRequest requestWithURL:downloadURL];
NKAssetDownload *assetDownload = [nkIssue addAssetWithRequest:req];
[assetDownload downloadWithDelegate:self];
[assetDownload setUserInfo:[NSDictionary dictionaryWithObjectsAndKeys:
	[NSNumber numberWithInt:index], @"Index", nil]];

Фичей NKAssetDownload есть background downloading. Она обязательна для Newsstand. Это означает, что загрузка будет продолжена в фоновом режиме. Там еще есть вкусности. Однако bg загрузка нас интересует, поскольку накладывает обязанность, при возвращении, вызвать у всех экземпляров NKAssetDownload метод downloadWithDelegate:

for (NKAssetDownload *assetDownload in [nkIssue downloadingAssets]) {
	[assetDownload downloadWithDelegate:self];
}

Тут, похоже, все. Реанимируем загрузку каким-нибудь прогресс баром и ожидаем ее окончания. По окончании загрузки, корректно, заменить иконку приложения: [[UIApplication sharedApplication] setNewsstandIconImage: [publisher coverImageForIssue:nkIssue]]. Если мы использовали архив, то разархивировать его. Ах, да, приложение, после загрузки журнала, обязано записать его файлы по пути свойства NKIssue contentURL (не уверен, обязано ли. Возможно, там что-то оптимизировано).

Далее, NKIssue принимает статус ''доступна'' — можно показывать ''брошюру''.

Следующий вопрос: обновление библиотеки — сводится к повторной загрузке issues.plist и его обработке имеющимся методом. Вспомните, там мы проверяли, был ли журнал добавлен в библиотеку иль нет. Правда, возникает предметная проблематика. Для ряда журналов, пользователь может забыть про него, к моменту выхода нового. И тут, как некстати, под рукой APNS. Это еще та тема. Признаюсь, с первого раза не осилил. Да и вообще, не я сделал эти уведомления в свой журнал. Ключевым камнем преткновения был сертификат, а точнее, надобность его конвертации.

Все-же, APNS: во первых, это сервис отправки уведомлений. Программка может получить id девайса, на котором она запущена и передать его на свой сервер. Сперва, нужно подписаться на эти уведомления

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // Register device for receiving push notifications
    [[UIApplication sharedApplication] registerForRemoteNotificationTypes:
     (UIRemoteNotificationTypeBadge | UIRemoteNotificationTypeAlert)];

Опосля, UIApplicationDelegate Protocol, имеет метод application:didRegisterForRemoteNotificationsWithDeviceToken:, в котором имеем DeviceToken (id) и из которого можно слить инфу своему серверу. Рядышком есть метод application:didReceiveRemoteNotification:, в котором можно что-то делать по получению уведомления.

На стороне сервера, нужно принимать запросы (HTTP), которые несут DeviceToken-ы и складировать их в БД. Далее, согласно купленным билетам, а точнее, согласно тому, что на сервере развернуто, нужно искать либу отправки сообщений на APNS. Согласно ее API, прикрепив сертификат и пароль, соединяемся и посылаем сообщения, которые содержат, как полезную нагрузку, так и DeviceToken-ы. Там еще есть нюансы, но это отдельная тема.

Помимо прочего, на iOS журнал накладывается еще два ограничения. Перовое — Privacy Policy URL. С технической стороны, тут крайне просто. Второе ограничение — необходимость распространения контента, используя iTunesConnect внутренние покупки. Как я понял, это означает, что пользователь обязан купить мой free журнал за 0 денег.

Итого, iTunesConnect In-App Purchases означает использование Store Kit. В UserDefaults я завел свойство isFreeSubscribed. По тапу проверяю его, и в случае NO показываю alert. По согласию, подписываю

SKProductsRequest *productsRequest = [[SKProductsRequest alloc]
	initWithProductIdentifiers:[NSSet setWithObject:@"FreeSubscription"]];
productsRequest.delegate=self;
[productsRequest start];

Плюс, надо реализовать методы делегата и установить isFreeSubscribed в YES, чтоб забыть про In-App Purchases. ProductId для SKProductsRequest нужно получать на сайте iTunesConnect, в разделе Manage In-App Purchases.

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

Автор: Shklyar

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js