- PVSM.RU - https://www.pvsm.ru -
Что вам кажется привлекательней: сталкиваться с новыми интересными задачами и разрабатывать нетривиальные алгоритмы или переписывать с одного языка на другой уже существующую логику и воевать со странными особенностями конкретных API? Я занимаюсь мобильной разработкой уже лет 8, и, не раздумывая, выбираю первый вариант, но и повоевать с API тоже люблю. Тем, кто со мной согласен, но еще не знает, как заниматься первым и сводить к минимуму второе, будет интересно заглянуть под кат.
В этой статье я поделюсь с вами своими соображениями об общих принципах портирования. Мы не будем влезать в дебри конкретных программных реализаций приложений под Android или iOS. Я просто постараюсь рассказать, как сделать так, чтобы приложение легко переносилось на различные устройства и могло называться кроссплатформенным.
Для начала определим круг задач. В этой статье мы не будем затрагивать встроенные системы, которые, несомненно, тоже работают на благо мобильных технологий. Мы рассмотрим только современные мобильные девайсы: телефоны и планшеты. Первая часть статьи будет посвящена архитектуре современных мобильных процессоров и ограничениям и, наоборот, фичам, которые получает от них мобильный разработчик. Во второй части я расскажу о некоторых базовых приемах, которые необходимы для написания портируемого кода.
Не так давно, еще лет пять назад, было очень много разных фирм, выпускавших мобильные процессоры со своими архитектурами. Однако сейчас основные архитектуры можно пересчитать по пальцам одной руки, и еще останется запас. Прежде всего, это ARM. Помимо него есть MIPS, которые обычно включены в Java NDK, и Intel со своим Atom-ом.
Все методы, о которых пойдет речь дальше, применимы ко всем этим системам. Но для удобства мы рассмотрим их на примере самых распространенных ARM-процессоров. Что нам нужно о них знать?
ARM сама не выпускает процессоры, она продает лицензию на выпуск, а различные фирмы (Samsung, LG, Broadcom, Apple, Qualcomm, Freescale) покупают лицензии и выпускают свои версии процессоров, одноядерные или многоядерные — какие им заблагорассудится.
Что дает лицензирование? Вы не просто берете ядро, вы делаете свой процессор. Вы можете его дорабатывать: улучшать или, наоборот, упрощать – это ваше право. Пример из истории – это известный Intel с процессором XScale [1]. Они купили лицензию на АRМv5TE и серьезно его переработали. На мой взгляд, это самая удачная из существующих пока переработок. Они улучшили работу с памятью, увеличили кэш, и, если сравнивать реализации Intel и Samsung на базе АRМv5TE, то Intel выигрывала раза, наверное, в два. Основной особенностью этого процессора было то, что в нем впервые появился сопроцессор с 40-битный аккумулятором, который позволял за 1 такт перемножать со сложением 4 числа, а старшие подели имели сопроцессор Wireless MMX, предоставляющий мощный набор SIMD инструкций
С точки зрения инженера, что такое система на кристалле? Это маленький микропроцессор, на который ставится куча разных устройств. Вы покупаете лицензию у ARM, ставите туда один, два -сколько хотите ядер. Дополнительные сопроцессоры (графические, DSP и др.), память (ROM, FLASH...) Помимо этого, обычно туда вставляют интерфейсы ввода/вывода: Ethernet, USB, COM-порты, если есть такая необходимость. Для того чтобы заставить все это добро работать, на этот же кристалл добавляются осцилляторы и таймеры, чтобы не выносить дополнительную обвязку отдельными блоками на плату.
Реализация системы на кристалле стала возможна для ARM, потому что эта система имеет очень мало транзисторов по сравнению с тем же Pentium. Благодаря этому она занимает мало места на кристалле, и технологически получается вместить туда все, что захотят разработчики. Вряд ли у нас бы получилось уместить все эти юниты в Core i5 — хотя бы потому, что он сам по себе большой, да еще и с большим радиатором (поскольку транзисторов много и все они сильно нагреваются).
Рассмотрим особенности архитектуры, которые важны для разработчиков.
Как развивалось и прогрессировало семейство ARM:
Хотелось бы отдельно остановиться на последней строке таблицы из предыдущего пункта. Что такое Thumb? Изначально это был набор упакованных ARM-инструкций. Если между быстродействием и компактностью кода мы выбираем второе, то короткие 16-битные инструкции с ограниченным функционалом – отличное решение.
Thumb2 – это эволюция Thumb. Она включает в себя часть инструкций ARM, и состоит из набора 16-битных и 32-битных инструкций, призванных обеспечить плотность кода как в Thumb и при этом сохранить производительность полноценного ARM.
Возьму на себя смелость дать определение кроссплатформенности: «Кроссплатформенным кодом можно считать код, для которого затраты по переносу на другую систему намного меньше, чем затраты на написание этого кода с нуля». То есть мы не называем портированием ситуации «у меня есть какой-то алгоритм, я хочу такой же сделать на iOS — соответственно, я должен такой же написать для этой платформы». Это не портирование, это написание заново. Чтобы портировать алгоритм, он должен отвечать условиям кроссплатформенности.
Принципы написания кроссплатформенного кода я определяю как «разделение и унификация». Постараюсь объяснить, что это такое. Эти пункты не являются постулатами, это мое видение.
Остановимся на каждом пункте подробнее.
#if defined(_MSC_VER)
typedef signed char int8_t;
typedef signed short int16_t;
typedef signed int int32_t;
typedef signed __int64 int64_t;
typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef unsigned __int64 uint64_t;
#elif defined(LINUX_ARM)
typedef signed char int8_t;
typedef signed short int16_t;
typedef signed int int32_t;
typedef signed long long int64_t;
typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef unsigned long long uint64_t;
#else
#include <stdint.h>
#endif
Что я имею в виду под единой типизацией? Вы просто определяете свои типы. В приведенном примере описан stdint. Он определен для компилятора Microsoft и для Linux. Если вы хотите большего контроля, вы можете написать «мой_любимый_int8», «мой_любимый_int16» и затем использовать эти типы в своей программе. Это очень помогает, например, если вы работаете с сетью: вы всегда уверены, что все пакеты у вас int16, расстояние между ними 0, смещение такое и никак иначе. Ведь если вы определите все через char, который внезапно станет двухбайтовым, все поедет куда-то не туда.
struct NetworkStatistics
{
uint16_t currentBufferSize;
uint16_t preferredBufferSize;
uint16_t currentPacketLossRate;
uint16_t currentDiscardRate;
uint16_t currentExpandRate;
uint16_t currentPreemptiveRate;
uint16_t currentAccelerateRate;
};
Так же для упрощения чтения и написания кода зависимого от платформы/компилятора/версии языка очень полезно вводить собственные унифицированные дефайны. типа таких:
#if defined(__APPLE__)
# ifdef TARGET_OS_IPHONE
# define MAILRU_OS_IOS
# elif defined(TARGET_IPHONE_SIMULATOR)
# define MAILRU_OS_IOS
# define MAILRU_OS_IOS_SIMULATOR
# elif defined(TARGET_OS_MAC) || defined (__OSX__)
# define MAILRU_OS_MACOSX
# else
# define MAILRU_OS_MACOSX
# endif
#elif defined(_WIN64)
# define MAILRU_OS_WIN64
# define MAILRU_OS_WINDOWS
#elif _WIN32
# define MAILRU_OS_WIN64
# define MAILRU_OS_WINDOWS
#elif ANDROID
# define MAILRU_OS_ANDROID
#else
# error Unsupported OS target!
#endif
#if defined(_M_X64) || defined(__x86_64__)
# define MAILRU_ARCH_X86_FAMILY
# define MAILRU_ARCH_64_BIT
#elif defined(__ARMEL__) || defined(__arm) || defined(_M_ARM_FP)
# define MAILRU_ARCH_ARM_FAMILY
# define MAILRU_ARCH_32_BIT
#elif defined(__ppc__)
# define MAILRU_ARCH_PPC_FAMILY
# define MAILRU_ARCH_32_BIT
#else
# error Please add support for your architecture in typedefs.h
#endif
В моей практике было очень много тяжелых алгоритмов, таких как голосовые и видеокодеки. Для простоты портирования желательно, чтобы алгоритм не использовал никаких системных функций, а еще лучше вообще обойтись и без системных библиотек.
Один из основных принципов написания портируемого и управляемого кода – не использовать malloc внутри алгоритмов. Ваш алгоритм должен определять, сколько ему потребуется памяти, передавать это значение вашему менеджеру памяти, который уже выделяет память и дает ссылку на выделенный кусок при инициализации. Алгоритм этим куском пользуется. Счастье и гармония.
// Interface
int32 MyAlgorithm_GetMemSizeNeed()
int32 MyAlgorithm_Init(void *memory)
void *MyAlgorithm_Destroy()
// Using
{
void *m = MyManager_GetMem(MyAlgorithm_GetMemSizeNeed());
MyAlgorithm_init(m);
//...
m = MyAlgorithm_Destroy();
MyManager_FreeMem(m);
}
Что происходит, когда ваш алгоритм сам выделяет память? Если вы часто создаете и убиваете процессы, и еще алгоритмов много, они начинают выделять и освобождать память хаотично, то начинается фрагментация памяти. Это приводит к тому, что в какой-то момент вы просто не сможете выделить необходимое количество памяти, даже если суммарный объем свободной память больше чем вам нужно. Оперативная память все еще довольно дорогой ресурс, особенно на мобильных девайсах.
По этому же принципу желательно выделять и другие системные функции.
Под базовыми операциями подразумевается выделение таких неспецифических операций, которых нет в С, но которые вы используете часто.
Рассмотрим простую операцию CLZ (Count Leading Zeros). Такой операции в С нет, и при необходимости можно написать алгоритм подсчета нулей. Фишка в том, что в некоторых процессорах, в частности в ARM он реализован на аппаратном уровне. В этих случаях мы спокойно пользуемся тем, что есть. Для этого компилятор может иметь интринсик-функцию. Если же компилятор такую функцию не реализует (как в нашем примере реализация GCC), можно поставить ассемблерную операцию. Для тех процессоров, в которых эта операция не реализована, придется уже писать отдельный код. Тем не менее, это все еще базовая операция. Чем это хорошо? Вы пишете базовую операцию один раз, вызываете ее в своем коде, и, если вам надо портировать, вы просто пишете юнит тест, портируете эту операцию, проверяете результат юнит-тестом. Если все OK, значит, код, который вы используете, тоже будет OK.
Чтобы продемонстрировать необходимость выделения базовых операций, приведу, казалось бы, простой пример: сдвиг для int64. В принципе, эта команда есть в С, но может быть реализована по-разному, поскольку это не стандартный int, а int64. Int64 в разных системах по-разному называются, но это полбеды. Самая большая беда в том, что выполняться они тоже могут по-разному.
uint64_t LogicalShiftRigth(uint64_t number, int shift)
{
return number >> shift;
}
uint64_t test = 0x1234567812345678;
uint64_t res63 = LogicalShiftRigth(test, 63);
uint64_t res64 = LogicalShiftRigth(test, 64);
Рассмотрим базовую операцию под названием logical shift right. Есть какое-то число, которое надо сдвинуть на 63 и на 64. Это граничные значения. Как известно, именно граничные значения приносят больше всего неприятностей. Что же будет в res63 и res64? По идее должны быть нули. Но на самом деле все не так просто.
В зависимости от разрядности платформы мы получаем разные результаты, хотя все делается основательно и по правилам. Именно поэтому лучше выделять спорные вещи в базовые операторы и при портировании отдельно делать на них тесты.
Аппаратно-зависимые части мобильного приложения обладают следующими особенностями:
Для того чтобы как-то унифицировать работу с аппаратно-зависимыми частями кода, хорошей практикой будет выделять набор базовых врапперов. Предложу свой вариант списка врапперов.
В зависимости от того, что делает устройство, вам могут понадобиться дополнительные врапперы. Вот краткий список аппаратных частей, которые делают обязательным применение врапперов (они необходимы для того, чтобы портирование прошло гладко).
Следуя всем вышеописанным рекомендациям, мы получаем алгоритм, который написан через единые типы, которые вы переносите. Этот алгоритм не требует вообще никакого вмешательства. По идее он должен скомпилироваться и заработать сразу. Лучше иметь к нему юнит-тесты. Если алгоритм скомпилировался и прошел проверки, скорее всего, все будет хорошо. Базовые операции тоже портируются, но их может и не быть.
Аппаратные части уже требуют портирования. И лучше, чтобы они имели как можно более близкий к системе интерфейс. Их будет легче проверить, и они быстрее портируются.
Наконец, я хотел бы привести некоторые общие рекомендации: на что следует обратить внимание при написании портируемого кода.
Если вы хотите посмотреть хороший пример реализации кроссплатформенного кода, я советую обратить внимание на libjingle. Там выполняются почти все постулаты, о которых шла речь в этой статье.
Если же вы знаете, как сделать портирование кода еще проще или хотите подробнее обсудить какие-то из изложенных мной пунктов, я с удовольствием пообщаюсь с вами в комментариях.
Автор: guaguanco
Источник [2]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/ios/36284
Ссылки в тексте:
[1] XScale: http://www.marvell.com/application-processors/pxa-family/
[2] Источник: http://habrahabr.ru/post/182506/
Нажмите здесь для печати.