Что это за приложение?
iTrace — это мобильное приложение для того, чтобы научить детей писать буквы. Электронные прописи на iPad. Сейчас она используется в нескольких странах мира (в основном в США) для обучения детей письму. Придумал и организовал всю работу по проекту Миша Богорад, а мне довелось участвовать в проекте разработчиком всяких внутренностей, главным образом, отрисовкой букв и анализом качества их рисования.
Идея, сложности
Идея iTrace ничем не отличается от обычных прописей. Берём букву, просим ребёнка её нарисовать, подсказывая, если ему трудно. Сначала буква большая и ошибиться можно сильно, потом она уменьшается, и допуск тоже всё меньше и меньше. В конце-концов ребёнок, благодаря привычке, запоминает, как пишется буква.
В статье я расскажу про сложности, с которыми пришлось столкнуться и то, как их удалось решить. Если тема окажется интересной, спрашивайте в комментариях, про техническую часть я могу рассказать подробнее.
Помимо организационных трудностей (нарисовать несколько тысяч картинок для призов и анимаций, найти специалистов по обучению, которые помогли проработать методологию, понять, где найти музыку и озвучку к приложению, и так далее), встретились и технические сложности. Три главных — оптимизация для работы на старых устройствах, отрисовка букв разной толщины и контроль качества ввода букв ребёнком.
Оптимизация
Задача оптимизации появилась из аудитории приложения. Дети часто используют старые Айпады, и, в частности, первый. Это не самое быстрое устройство, с не самым большим количеством памяти. Пришлось придумать, как работать с анимацией, какого она должна быть размера, чтобы работать на старом железе, сколько можно сделать кадров в секунду и так далее.
Также пришлось оптимизировать работу с ресурсами в приложении. Казалось бы, просто положи их в бандл и всё, что там оптимизировать, но, увы. Во-первых, сами ресурсы большие. Потребовалось каждую картинку прогнать через оптимизатор, проанализировать, какой формат минимальный, выбрать лучший. Частично это делает система сборки проекта, но лучше контролировать самому. Во-вторых, установка тестовой сборки (и, как следствие, установка самого приложения на Айпад) занимала огромное количество времени. Оба этих процесса связаны с копированием и распаковкой файлов, и когда количество файлов — несколько тысяч, процесс может занять существенное время. Я не зря сначала вспомнил про первый Айпад. Установка тестовой версии на это устройство занимало порядка 10 минут.
В этот момент я вспомнил про пак-файлы. Это техника, которая давным-давно применяется в играх, где много-много текстур. Все файлы запаковываются в один файл, работу с которым можно организовать очень эффективно (смаппить, например). Можно было бы придумать свой формат, но мне удачно подумалось попробовать zip без сжатия и по тестам, скорость была почти такой же, как и доступ к файлам напрямую.
Единственная проблема, которая немного мешала — zip-файлы лишены случайного доступа к файлам, пришлось строить свою собственную таблицу соответствия именам местоположения файлов, которая в простейшем случае выглядит вот так:
{"Levels":{"":[3149,48427877],"iphone_cursive_word_levels.csv":[3153,48428251],"iphone_cursive_levels.csv":[3152,48428149],"iphone_regular_levels.csv":[3154,48428358],"regular_word_levels.csv":[3157,48428662],"save_before_rollback.zip":[3158,48428762] ...
После чего немного соптимизировать её по памяти (для большого количества файлов она занимает существенное место), по скорости загрузки (она кешируется при старте приложения), но после этого приложение стало устанавливаться за 20–40 секунд.
Код DPLPacker'а можно глянуть тут: https://github.com/bealex/DPLPacker А простейшая работа с паком выглядит так:
Создаем файл
_zipFile = [[DPLZipFile alloc] initWithZipFile:_zipFilePath];
Проверяем, что файл есть в архиве, получаем его (по пути и имени):
NSData *data = nil;
if ([_zipFile fileExistsForPath:filePath]) {
data = [_zipFile dataForPath:filePath];
}
return data;
Нужно не забыть аккуратно разобраться с @2x-картинками, если пакуем их. В отличие от штатных файлов, система не загрузит за нас нужную версию:
- (UIImage *)imageAtPath:(NSString *)filePath {
CGFloat scale = 1;
if (DPL_isRetina()) {
if (![filePath contains:@"@2x"]) {
NSString *filePath2x = [filePath stringByReplacingOccurrencesOfString:@".png"
withString:@"@2x.png"];
if ([self fileExistsAtPath:filePath2x]) {
filePath = filePath2x;
if (DPL_isIPad()) {
scale = 2;
} else {
scale = 1;
}
}
} else {
scale = 2;
}
} else {
if (![filePath contains:@"@2x"] && ![self fileExistsAtPath:filePath]) {
NSString *filePath2x = [filePath stringByReplacingOccurrencesOfString:@".png"
withString:@"@2x.png"];
if ([self fileExistsAtPath:filePath2x]) {
filePath = filePath2x;
scale = 2;
}
} else {
scale = 1;
}
}
NSData *data = [self dataWithContentsOfFile:filePath];
if (fabs(scale - 1) > 0.01) {
if (DPL_OSVersionMajor() >= 6) {
return [UIImage imageWithData:data scale:scale];
} else {
return [UIImage imageWithCGImage:[UIImage imageWithData:data].CGImage
scale:scale
orientation:UIImageOrientationUp];
}
}
return [UIImage imageWithData:data];
}
Насколько кривая эта кривая?
Во всех остальных приложениях, которые обучают детей письму букв, самого обучения не происходит. Ребенку просто показывают, как должна писаться буква, а дальше он может рисовать её, как хочет. Нет никакой обратной связи, никакой возможности проверить правильность, поправить при необходимости. Почему это важно? Потому что есть школа, которая учит определённым образом (в США три базовых варианта написания букв, плюс курсив, варианты для правой/левой руки и мелкие отличия между разными школами).
Если ребёнок научится в приложении писать «как попало», ему придётся переучиваться. Это будет сложно, больно и неприятно.
Поэтому основной фичей, которую хотелось реализовать, должна была стать проверка корректности написания. Если ребёнок повёл линию не туда, нужно сразу ему подсказать, что не туда. Если начал не из начала — подсказать, где оно, начало. Эту задачу и пришлось решать.
Сложность составилась из двух частей. Первая — как сделать обработку быстрой. Она не должна существенно задерживать рисование буквы даже на старом, первом, Айпаде. Вторая — как именно определить, что ребёнок сделал «не то». Вот примеры ошибок, которые iTrace отлавливает:
IWTaskErrorCodeErrorTooBig = 1, // накопилась слишком большая ошибка
IWTaskErrorCodeLineExitedCorridor = 2, // вышли за коридор
IWTaskErrorCodeCornerDrawingDistanceWrong = 3, // слишком срезали или "накрутили" угол
IWTaskErrorCodeStraightLineDrawingDistanceWrong = 4, // далеко отошли от середины
IWTaskErrorCodeCornerDoubleEnter = 5, // вернулись уже в нарисованный угол
IWTaskErrorCodeLineNotCovered = 6, // не около любой точки идеальной кривой нарисовали
IWTaskErrorCodeWrongStart = 7, // если начали не оттуда (надо это как-то проверять — грубо говоря, если первая точка вне первого угла, или если ошибка в первом углу)
IWTaskErrorCodeTooOverextended = 8 // если перевели за конец. Это, видимо, чуть сложнее, но это важно, поскольку это очень частая ошибка. Идея примерно такова — если мы дошли до последнего (финального) угла, а потом обломали пользователя по причине того, что слишком длинная кривая в углу или по причине выхода за границы коридора — сохраняем в истории вот эту ошибку.
Первая часть решилась сравнительно обычными приёмами. Сначала я реализовал все алгоритмы, используя высокоуровневые структуры (классы Objective-C, коллекции оттуда же), но, увидев в профайлере, что слишком много времени тратится на работу с ними (даже на боксинг/анбоксинг чисел из NSNumber), перешёл на обычные С-структуры. После чего ввёл несколько кешей, чтобы пересчитывать только конец нарисованной линии, а не её всю. Это позволило убрать тормоза при рисовании длинных линий, и добиться нужной производительности.
Главная задача определения «что ребёнок сделал не то» состояла в том, чтобы определить, что такое «не то». Какие бывают ошибки? Мы выделили несколько:
- начало рисования не из правильной точки,
- незавершение линии,
- слишком далеко отошли вбок от идеальной линии,
- пошли «не в ту сторону». Эта ошибка отличается от предыдущей, так как мы можем пойти обратно по идеальной же линии,
- срезали угол,
После чего попробовали придумать, как формализовать все эти ошибки. Понятно, что нужно сравнивать идеальную линию и нарисованную. Понятно, что нужно разбивать их на небольшие отрезки, сравнивать их и потом аккумулировать накапливаемую ошибку. Сложность оказалась в углах. Совсем выкидывать их нельзя, углы — важная часть буквы. Но ошибка в них накапливается очень просто и очень быстро.
После двух месяцев проб и ошибок получился примерно такой алгоритм:
- разбиваем кривую на «линейные отрезки»,
- между отрезками у нас появляются области, которые мы назовём «углы». Угол — это просто небольшой отрезок, где направление линии меняется резко. Это может быть либо настоящий угол, либо какая-нибудь петелька, либо начало/конец линии.
- на линейных отрезках мы считаем, как раньше считали. Смотрим схожесть направлений отрезков и расстояние между идеальной/нарисованной кривой. Накапливаем ошибку.
- в углах мы смотрим на разницу между длиной идеальной и нарисованной кривой. И всё. Удивительным образом оказалось, что если правильно подобрать допустимую разницу, то это простое правило хорошо проверяет рисование углов.
На изображении видны углы (большие окружности). Ищутся они автоматически, по кривой, заданной в SVG-формате.
Русский язык
Логично было предположить, что приложение потребуется локализовывать на разные языки. Но какие языки это будут и когда именно после старта это будем делать, не было понятно. И разработка велась без оглядки на какой-то другой язык.
Когда же появилось желание и возможность поддержать русский язык, выяснилось, что это не совсем просто.
Во-первых, нужно переводить интерфейс. Причём, если интерфейс должен быть привязан к языку системы, то язык обучения может быть другой. Нужно уметь их переключать. И нужно сделать так, чтобы длиннющий русский влезал везде в интерфейсе.
Во-вторых, красивый шрифт, который мы использовали, не содержал русские буквы. Пришлось заказать доработку шрифта.
В третьих, понадобилось больше картинок. В русском языке больше букв, больше упражнений. Кроме картинок необходима была также новая озвучка.
В четвёртых, потребовалось доработать алгоритм работы с буквами. Диакритика («й», «ё»), мелкие штрихи («ц» или «щ») — всё это усложняло алгоритм контроля качества рисования.
Очень кстати пришлась запаковка ресурсов в файл. Создав несколько таких пакетов и переключая эти файлы, оказалось очень удобно переключаться между языками.
Мелочи
Конечно же, было и много мелочей в разработке. Например, iTrace умеет печатать «настоящие» прописи, бумажные. Чтобы детям было интереснее, снизу каждой прописи рисуется лабиринт. Он генерируется каждый раз заново, занятно было подобрать параметры так, чтобы детям было и интересно, и не сильно просто/сложно.
Были проблемы и с апрувом. Например, когда мы попробовали в первый раз включить Touch ID для парент-гейта (спец-задачи, которую решают родители, но не дети, для входа в настройки приложения), нам отказали, сказав «нельзя». Пришлось пообщаться с представителями Apple, придумать более аккуратный алгоритм работы с Touch ID, после чего фичу приняли.
Также мы не сразу придумали, как правильно делать покупку внутри бесплатной версии. Сначала хотели сделать покупку каждой мелкой фичи, но после обсуждения решили сделать только одну покупку «на всё».
Интересно было и сами буквы рисовать. Они рисуются много где, и в прописях пунктиром, и разной толщиной на экране рисования, и в истории, где можно просмотреть попытки ребёнка рисовать буквы вместе с ошибками… везде свои требования, свои сложности.
В результате получилось отличное приложение. Посмотрите. Есть и бесплатная версия, с покупкой внутри, и платная. Более 350 тысяч человек уже посмотрели, и многим нравится. :-)
Автор: bealex