Ангстрем. Кучка сложностей в простой обёртке

9b491051bc454928a3e1a3f7e6eda0c6.png

Когда требуется ещё один велосипед?


Ангстрем, безусловно, если смотреть на выполняемую функцию, велосипед. Сколько способов преобразовать единицы? Много. Можно пользоваться гуглом, можно одним из сотен приложений для iOS или Android.

Но, вместе с тем, ни один способ не решал одну проблему. Как мне получить результат конвертирования, когда я смотрю сериал? Конкретно, Mythbusters. Они там всегда общаются между собой про футы и фунты. Сколько это? Большая ли квартира, 500 ft²? (не очень, как оказалось) Много ли это, 27 psi (угу, дофига)? И, наконец, скажите им, что Фаренгейты — вообще никому не понятны!

С обычными конверторами приходится останавливать видео, выяснять, какая это категория, «psi», потом искать там этот самый «pounds per square inch», вспоминать, какое число нужно ввести, понять, во что её перевести (чтобы осознать масштаб проблемы). Делать это хочется с тем устройством, которое под рукой, желательно без интернета.

И вот эту проблему не решить ни одним конвертером. Я перепробовал, наверное, сотню. Она решается гуглом, но это тоже медленно (запустил браузер, ввел что-то в строке, гугл не понял, или понял не так…).

Так что велосипед ли Ангстрем? Вроде бы нет.

Поглядим теперь на сложности, которые пришлось решить при его разработке. Технические сложности, программерские.

Коротко о дизайне


Существенная часть сложностей выросла из великолепного дизайна, который придумал Илья Бирман. Без него Ангстрем бы назывался GeeKonv и выглядел бы как-то так:

0ba0b893b6884041b7c9bec58627d427.png
Вместо этого получилось приложение, на которое приятно смотреть, и которым удобно пользоваться.

ad8e5e62b1f7401b8e0a5a5a6a8bc31e.png
Немного про то, какие вопросы приходилось решать про UI, можно почитать у Ильи в разделе блога про Ангстрем, а я продолжу про технику.

Вычисление формул


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

Вычисление формул — непростая задача. Их нужно как-то записывать, как-то парсить, как-то подставлять переменные, и так далее. По счастью, в процессе исследований я наткнулся на статью, рассказывающую о побочном свойстве NSExpression, которое позволяет вычислять некоторые арифметические выражения. Работает оно вот так:

[[NSExpression expressionWithFormat:@"(23-7.5)*40.0/21.0+273.15"] expressionValueWithObject:nil context:nil]


И позволяет вычислять что-то простое (как в примере), или, используя функции, перечисленные в документации, чуть более сложное.

Работая с вычислениями через NSNumber и NSExpression, нужно не забыть определить поведение NSNumber в случае неверных результатов. В противном случае вычисления будут ломаться. Делается это при помощи такого кода:

[NSDecimalNumber setDefaultBehavior:Класс, наследующийся от NSDecimalNumberHandler]


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

@implementation CONDecimalNumberHandler
    - (NSRoundingMode)roundingMode {
        return NSRoundBankers;
    }

    - (short)scale {
        return NSDecimalNoScale;
    }

    - (NSDecimalNumber *)exceptionDuringOperation:(SEL)operation 
                                            error:(NSCalculationError)error
                                      leftOperand:(NSDecimalNumber *)leftOperand
                                     rightOperand:(NSDecimalNumber *)rightOperand {
        NSLog(@"Error during parsing number: %@/%@ (%d)", leftOperand, rightOperand, (int) error);
        if (error == NSCalculationOverflow || error == NSCalculationUnderflow) {
            return [[NSDecimalNumber alloc] initWithString:@"0"];
        } else {
            return [[NSDecimalNumber alloc] initWithString:@"1"];
        }
    }
@end


Хранение таблиц с единицами


Сами списки единиц тоже хранить непросто. Ведь нужно:

  • Понимать коэффициенты или формулы для каждой.
  • Хранить приоритеты, чтобы можно было выбирать наиболее правильную единицу для каждого конкретного случая.
  • Для этого же хранится тип системы измерения единицы (СИ или имперская, например).
  • Переводы названий единиц делаются для каждого языка и для всех возможных словоформ (для русского языка, например, пять словоформ). Сюда же относятся возможные символы для единицы, синонимы названий (ар или сотка или квадратный декаметр).
  • Некоторые единицы объединяются в «кластеры», например, 1 метр переведётся в футы, а 10 метров — в ярды. Вторая единица выбирается из кластера.
  • Наконец, сами единицы нужно объединить в категории, чтобы правильно выводить в меню, и учитывать эту информацию при форматировании/переводе единиц.


Исходно единицы я храню в виде текстовых файлов (так их проще редактировать), отдельно — базовая информация, отдельно локализация для каждого языка файл. Вот файл для скоростей (бесплатных)

knot    kn,kt   0.514444    3   impAdd
# На высоте 11 км из-за падения температуры скорость звука ниже — около 295 м/с или 1062 км/ч.
Mach    M   295.0464    2   other
speed of light  c   299792458   0.11    siAdd
meter per minute    m/min   60  0   siAdd2
centimeter per second   cm/s    100 0   siAdd2
^minutes per kilometer  min/km  FORMULAE(16.666666667/X,16.666666667/X) 0   other
^minutes per mile   min/mi  FORMULAE(26.805555556/X,26.805555556/X) 0   other


Из них я получаю JSON-файлы, которые изначально и использовались для работы. К сожалению, это оказалось недостаточно гибко и быстро. Поэтому сейчас все данные пакуются в SQLite-базу и читаются оттуда по необходимости. Формат базы повторяет, более-менее, структуру JSON-файла, которая была такой:

[
    {
        "fml":   "",
        "abbrs": [
            "m\/s"
        ],
        "us":    "si",
        "id":    1,
        "tag":   "meter per second",
        "pri":   3,
        "to":    2000002,
        "cof":   1,
        "names": [
            "meter per second"
        ]
    },
    ...
]


Кроме базы с основными данными ещё нужно дерево поиска. Ангстрем умеет работать в двух режимах:

  • перевод внутри приложения, когда есть три строки «число», «единица раз», «единица два». В этом случае нужно найти две единицы по строкам, и потом вычислить результат. Распознавание идёт, как обычно. Бегаем по дереву с подстроками, в узлах которого живут буквы, и к каждому из узлов привязан список единиц. Добежали до нужного узла, получили список единиц, отобразили варианты (или взяли первый, если нужен только один).
  • перевод строки. Например, мы надиктовали что-то (на часах, или при помощи стандартной диктовки), или скопипастили из другого приложения. В этом случае процедура примерно следующая:
    • Разбиваем строку на слова
    • Вычищаем совсем уж лишнее, готовим, делаем первую порцию магии (например, second заменяем, так как система распознаёт это не как «секунду», а как «второй», что обычно неверно).
    • Парсим число. Тут применяется вторая порция магии. Дело в том, что NSNumberFormatter умеет парсить строки-как-числа, например, «thirty-four» оно умеет распознавать, как »34». Это суперская фича, которую неимоверно сложно использовать правильно, так как у нас не просто число, а строка, из которой это число нужно выделить. Приходится бежать по словам, используя всё расширяющиеся диапазоны, чтобы попытаться распарсить максимально большое число.
    • Оставшиеся слова пробуем распарсить на юниты, либо один, либо два. Тут магии больше всего, так как говорить пользователь может как хочет, в любой последовательности. Приходится создавать кучу эвристик, которые правильно реагируют на те или иные маркерные слова. В основе тут всё тот же поиск по дереву единиц, что и в обычном варианте.


Оба этих режима используют дерево единиц. Можно было бы использовать стандартный текстовый поиск, например, из SQLite, но тогда пришлось бы сильно возиться с токенайзерами и настройками, поэтому я решил просто написать свой. Сложность и там и там похожая, но со своим у меня больше возможностей по оптимизации.

Узлы дерева поиска хранятся в отдельных файлах. Вот таких (я взял очень коротенький):

{"p":"наб","u":{"":[[1,13,631]]},"s":{},"f":"ережныечелны"}


Это позволяет не хранить его целиком в памяти, загружая по необходимости. Дерево большое, и это сильно ускоряет запуск, работу на старых устройствах (Ангстрем нормально работает на iPhone 4) и уменьшает нагрузку на память.

Файлы я упаковал DPLPacker'ом, про который написано в моей статье про iTrace. Их на настоящий момент почти 7500, и без упаковки пришлось бы очень плохо.

Вся информация про единицы в Ангстреме занимает сейчас примерно пять мегабайт, а файл приложения в сторе — 13.2 мегабайта. Сразу видно, приложение — про перевод единиц :)

Оптимизация


Оптимизация — вопрос, который далеко не всегда встаёт перед разработчиками приложений. Скорость развития техники позволяет иногда либо просто забить на это, либо сделать «что-то простое», и хватит. Ангстрем же приходится использовать в достаточно экстремальных условиях, например, на Apple Watch, или на стареньком iPhone 4 (пока поддерживается iOS 7). На этих устройствах мало памяти и сравнительно небыстрый процессор. Поэтому оптимизировать приходится всё, и при этом не забывать, что в будущем может быть в 10 раз больше разных единиц (сейчас их примерно 1050).

Главных моментов для оптимизации три:

  • Старт приложения. Для отладки старта используем Instruments, и выкидываем из старта всё, что можно. Все обновления — начинаются через пару секунд после старта. Никаких загрузок единиц, кроме тех, что на экране, никаких тяжёлых ресурсов.
  • Поиск единицы. Для этого я сделал дерево поиска, и разбил информацию по буквам на отдельные файлы. Ввели букву — загрузили ровно файл про эту букву, ничего больше. В самих файлах информация хранится в очень компактном виде (айдишки, простые списки).
  • Использование памяти. Приёмы тут похожие на предыдущий пункт, так как основной потребитель памяти — как раз база по единицам. SQLite, вместе с фрагментированным по файлам деревом поиска неплохо решают проблему.


Ещё до разработки Ангстрема я узнал, что очень удобно, когда есть задача, которая чрезвычайно ограничена по какому-то ресурсу. Например, для работы приложения на Apple Watch, нужно оптимизировать скорость, первая версия часов очень, очень медленная, а парсить приходится естественный текст, это занимает существенное время. Также, в версии 1.8, парсинг происходит сразу на нескольких языках, чтобы можно было продиктовать по-русски, даже если интерфейс по-английски (сам диктейшн не выдает никакой индикации про то, какой язык сейчас используется). Оптимизация под такое, «плохое» устройство, улучшает производительность и для остальных, более современных и быстрых.

Чтобы сделать Today Extension (сейчас он выключен, так как глючит и плохо работает), требовалась жесточайшая оптимизация по памяти. Хотелось, чтобы он умел парсить строку из буфера обмена (а не просто показывать пару строчек), это требовало вполне полноценного приложения. Забавный момент, что Apple говорит про объём памяти, доступный такого рода расширению. «Мало», говорят. Сколько это — мало? «Чем меньше, тем лучше!» Никаких конкретных чисел. Поэтому оптимизация по памяти — до предела.

Это всё доводит требования по оптимизации до экстремума. Приходится применять все известные техники, придумывать новые структуры данных (точнее, использовать хорошо забытые старые), тыкать палочкой в параллельность, внимательнейшим образом смотреть на то, что показывает Instruments, время от времени выкидывать алгоритмы, которые тормозят, в пользу более сложных, но и более эффективных.

Я даже советую иногда, если разрабатывается приложение, взять самое тормозное устройство, которое есть, и запустить, погонять на нём. У меня специально для этого лежит и iPhone 4, и iPod Touch пятого поколения (там то же железо, что и в iPhone 4S). Первый почти неактуален, а вот второй — будет актуален ещё год-полтора (на него встаёт все, включая последнюю на сегодня iOS 9).

Техника. Внешний вид


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

Клавиатура


Клавиатура в Ангстреме показывается всегда, кроме случаев, когда она не показывается. Она сдвигается (попробуйте посвайпить вправо от первого экрана), она пропадает (подключите внешнюю клавиатуру или скройте её на Айпаде), и она бывает разная (Айфон/Айпад/Айпад Про). К ней также привязана наша цифровая клавиатура, которая бывает узкая

6071b7ff53ae456ea5f2ba46a101a802.png
Бывает высокая

a9d0c415c36045ed87c4ae7c4622c244.png
Бывает Айпадная

0bd150e2c3df4728a6a78d5a1fafedc6.png
В версии 1.8 она научилась переключаться, чтобы уметь вводить шестнадцатеричные или римские цифры. Подытоживая, там всё сложно.

Расскажу я про две вещи. Как сдвинуть клавиатуру, и как сделать, чтобы кипад (наша цифровая) работал в Accessibility.

Чтобы сдвинуть клавиатуру, нужно понимать устройство окошек в iOS. Для каждого приложения выделяется своё окно (UIWindow), но если появляются модальные диалоги или клавиатуры, то количество окошек увеличивается. Если использовать что-то вроде Reveal, то иерархия видна очень хорошо:

f3a75c46dd694de79d7750f8ea0f156e.png
На картинке (от дальних к ближним) слои:

  • UIScreen
  • главное окно приложения
  • окно, в которое система засовывает кастомную клавиатуру
  • окно клавиатуры


Тут сразу видно (за это я и люблю Reveal), что и как нужно двигать, чтобы работало. В результате главное окно я двигаю, как хочется (у меня над ним полный контроль), свою клавиатуру тоже я, по-крайней мере, могу получить по ссылкам и подвинуть:

_keypadView.transform = CGAffineTransformMakeTranslation(_keypadView.virtualFramePositionX, 0);


Окно клавиатуры же я могу получить перебором всех окон приложения, или просто попробовав получить последнее окно, если оно похоже на клавиатуру:

NSArray *windows = [UIApplication sharedApplication].windows;
if ([NSStringFromClass([windows.lastObject class]) contains:@"Keyboard"]) {
    _keyboardWindow = windows.lastObject;
}


Почему я не использую перебор всех окон в приложении каждый раз, кешируя значение? Это достаточно сильно тормозит в iOS 9 (а раньше было нормально). Поэтому приходится оптимизировать (код должен работать 60 кадров в секунду, при интерактивных свайпах). Для ускорения я также проверяю, что frame окна действительно поменялся и обновляю его исключительно в нужной ситуации. И не frame, но только center, так как размеры окна всегда остаются прежними, а смена frame может повести за собой гораздо более серьёзные изменения, чем просто изменение положения вьюхи.

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

  • Отсутствии клавиатуры в модальных диалогах. Например, если в Эбауте Ангстрема открыть создание письма, в диалоге может не быть клавиатуры (а может быть). Это было в старых версиях iOS, когда одна и та же клавиатура использовалась для всех окон. Уехали одну, уехали все остальные. Нужно возвращать.
  • Сдвинутых элементах. В том же диалоге создания письма, например, может сдвигаться UIMenuController.
  • В сложных случаях работы с клавиатурой (уехала, показалась для модального контроллера, вернулась) — клавиатура может пропасть. Видимо, само окно клавиатуры время от времени пересоздается и старая ссылка теряется.


Также будьте готовы к тому, что клавиатура может пропасть (или появиться другая). Например, при покупке расширенного набора единиц — появляется системная клавиатура для ввода пароля. После завершения процедуры покупки (как бы он ни завершился), нужно вернуть клавиатуру обратно.

В общем, на самом деле, рекомендация простая. Не трогайте клавиатуру без повода, пускай этим система занимается. Иначе вляпаетесь, как я вляпываюсь, граблей там очень много.

Accessibility


Мне очень хотелось, чтобы наш кипад, пусть даже и не выглядящий, как стандартная клавиатура, вёл себя похожим образом. Сначала я попытался найти, как воспроизводить звук нажатой клавиши. Радостный, я узнал про UIInputViewAudioFeedback, и про метод [[UIDevice currentDevice] playInputClick], который делает ровно то, что нужно.

После этого мне потребовалось поддержать accessibility, то есть работу приложения, когда им пользуются пользователи с ограниченными возможностями. Для обычных компонентов интерфейса нет ничего проще. Задаём для компонента несколько пропертей, и всё.

self.isAccessibilityElement = YES;
self.accessibilityLabel = @"Кнопка зелёная";
self.accessibilityHint = @"Нажимайте, если хотите сделать хорошо";
self.accessibilityValue = @"Нажата";


С кипадом всё оказалось сложнее. Рисую я его целиком, чтобы проще было нарисовать фоновый градиент, а в более старых версиях и углы скруглить.

Чтобы поддержать нормально и клавиатурное поведение (клики), и всё остальное, пришлось поверх нарисованной клавиатуры создать прозрачные кнопки-наследники UIButton, которым правильно прописать значения, и внимательно следить за тем, чтобы они менялись при смене клавиатуры (в последней версии появился ввод римских и шестнадцатеричных чисел).

Если тема Accessibility вам интересна, могу рассказать про неё подробнее. Или можно поглядеть соответствующие сессии с WWDC, они очень хорошие: iOS Accessibility и Apple Watch Accessibility

Accessibility помог мне ещё и при тестировании, о чём я подробнее рассказывал в своём блоге.

Вопросы?


Может, интересуют какие-то другие особенности или подробности реализации? Спрашивайте!

© Habrahabr.ru