Из этой статьи вы узнаете, как эффективно организовать очень важную часть разработки на React Native - работу со стилями и ресурсами для создания адаптивных и доступных интерфейсов под три платформы: iOS, Android и Web. Также в целом обсудим особенности верстки и проблемы производительности в рамках данного фреймворка.
Для React Native, помимо стандартных StyleSheets
, существует множество библиотек для стилизации интерфейсов, таких как NativeWind, Tamagui, Dripsy, Styled Components. В сети можно найти сравнительные бенчмарки для этих библиотек, после изучения которых можно сделать однозначный вывод: все они так или иначе ухудшают производительность, причем иногда - на порядок. Впрочем, данный вывод можно было сделать и логически - большинство библиотек вычисляет стили во время выполнения, создают обертки над компонентами, увеличивая VDOM, и в целом выполняют куда больше кода, нагружая и без того перегруженный всем чем только можно поток JS, ответственный как за рендеринг, парсинг данных и бизнес логику, так и за обработку нажатий.
Имея 8 лет опыта и 13 проектов только на React Native, могу смело утверждать, что нагружать поток JS неоптимальным кодом и лишней работой часто плохо сказывается на отзывчивости приложений, особенно на слабых Android устройствах. И неоптимальный рендеринг VDOM с последующей сборкой мусора часто стоит на первом месте по времени выполнения. Но стоит отметить, что если писать код осмысленно, то с производительностью проблем не будет.
Также, бездумное добавление сторонних библиотек, тем более нативных, часто приводит к всевозможным ошибкам на всем многообразии устройств и операционных систем, особенно после очередного обновления этих самых библиотек или фреймворка. Главный принцип разработки на React Native (и не только) - добавляя стороннюю зависимость, ты добавляешь все баги (GitHub Issues) этой зависимости в свой проект, а в случае с нативными библиотеками - умножай их количество на количество платформ.
А зачем вообще использовать библиотеки для стилей? Неужели встроенного в RN функционала не хватает? Или это банальная привычка рукожопов новичков тянуть в проект зависимости чтобы сложить 2 + 2? В документации React Native действительно не хватает лучших практик для верстки под разные темы и размеры экранов окон, но все таки делать это довольно просто, и скоро я покажу как. Так что скорее второе.
Задача
Сделать адаптивный и доступный интерфейс для платформ iOS, Android и Web, включая десктоп, планшеты и разные ориентации экрана. Минимальная поддерживаемая ширина окна - 320px.
Как НЕ надо
Почему НЕнативные технологии часто считаются хуже, даже если они лучше? На них всегда можно переложить ответственность за все проблемы. Что происходит регулярно.
Многие начинающие разработчики возьмутся хардкодить всевозможные глобальные константы типа
isLargeScreen
,isPhone
,isTablet
и т.д. и т.п., и повсеместно использовать их для вычисления стилей, превращая код в такую кашу, в которой можно годами править баги верстки разных экранов и платформ, и так и не поправить. В случае со статическими стилями конечно же ни о какой адаптивной верстке не может быть и речи - изменение ориентаций, размеров окон и масштабирование в браузере поддерживаться не будут. В дальнейшем виноват будет фреймворк React Native, потому что разработчик, и тем более его руководитель - не виноваты (если их спросить).Самые
одаренныенеопытные бросятся умножать размеры шрифтов, отступов и элементов на какой нибудь scale, полученный исходя из деления размера экрана на магическое число. Такую верстку, да вкупе с предыдущим пунктом, можно смело выкидывать на помойку целиком. Запомните - размер пальцев и глаз людей не зависит от размера и разрешения экрана, а значит и размеры кнопок и шрифтов не должны от него зависеть. Также, по требованиям доступности нажимаемые элементы не должны быть меньше 44 пикселей, и у шрифтов тоже есть свои ограничения. Отступы уменьшать допустимо, но об этом позже. Также важно понимать, что в React Native все размеры указываются не в пикселях, а в логических пикселях (или по другому - масштабируемых пикселях), не зависящих от плотности пикселей экрана, и также не нуждаются в дополнительном масштабировании.Другая часть выберет путь использования сторонних библиотек для стилизации, чем поставят крест на высокой производительности приложения, помножат количество багов и увеличат кривую онбординга на проект очередной криво написанной документацией. В дальнейшем для оптимизации и исправления багов придется переписывать все компоненты.
По поводу вынесения размеров шрифтов и всевозможных отступов в глобальные константы / тему - в абсолютном большинстве случаев это не только не имеет смысла, но и вредно - за мои 30 проектов мобильных приложений на разных технологиях не было ни одного случая, когда дизайнер захотел бы, например, изменить все размеры шрифтов с 14 до 15, или все отступы 16 заменить на 18, и весь этот оверинжиниринг хоть бы раз пригодился. Пара возможных исключений: 1) наличие стандартизированной дизайн системы, где размеры еще и зависят от размера окна 2) значения, либо стили схожих компонентов, которые точно должны быть одинаковыми - в этом случае можно экспортировать общие стили либо вынести значения в константы. В остальных случаях - хардкодим и не дурим друг другу головы.
Внешние отступы у компонентов. Их по умолчанию быть не должно. Если они нужны, пусть передаются через свойства -
style
, либо отдельные. Следует максимально где возможно использоватьgap
,rowGap
,columnGap
у контейнеров, чтобы в принципе минимизировать использованиеmargin
. Поддерживать код, в котором у компонентов по умолчанию заданы отступы, и переопределены во многих местах - это очень больно и багоемко.
Стоит отметить, что почти все перечисленные проблемы характерны и для нативной мобильной разработки.
Как можно и нужно
1. База
Помним, что любое приложение находится в окне. Размер экрана нам не важен. Важен размер окна.
По требованиям доступности, десктоп сайт должен без проблем масштабироваться до 400%. Поддерживая это требование, мы автоматически поддерживаем и мобильные устройства - ведь во всех браузерах масштабирование делается путем симуляции уменьшения ширины экрана и приближения, и верстка на большом масштабе аналогична экранам телефонов, без дополнительных усилий. Поэтому здесь мы убиваем двух зайцев.
Для нас телефон, планшет и десктоп, да в разных ориентациях - ничем не отличаются, кроме ширины окна, поэтому поддерживая всего лишь разную ширину окон, мы автоматически поддерживаем все типы устройств и все ориентации. Чаще всего планшеты и телефоны в горизонтальной ориентации идентичны десктопу. Еще минус несколько проблем. Делать различия для сенсорных и не сенсорных экранов на практике не было необходимости, и в данной статье этот момент не рассматривается.
Flexbox - единственный доступный из коробки, но очень мощный инструмент для верстки, редко требующий хардкода платформ, размеров окна и родительских компонентов. Если есть недостаток понимания его работы, то обязательно стоит пройти какие нибудь курсы:
flexDirection
,flex
,flexGrow
,flexShrink
,flexWrap
,gap
,alignItems
,justifyContent
,minWidth
,maxWidth
и многое другое - нужно знать.
2. Стили
Заранее стоит отметить, что все приведённые ниже фрагменты кода представляют собой лишь примеры реализации различных подходов, которые можно адаптировать под конкретный проект. При этом важно помнить главный принцип разработки — KISS (Keep It Simple, Stupid) — и избегать добавления кода в проект, пока в нём действительно не возникнет необходимости.
Хранить стили лучше в файле с компонентом по трем причинам: а) не придется создавать папки для простых компонентов б) не нужно писать дополнительный import и перескакивать между файлами в) поддерживаются правила eslint
react-native/no-unused-styles
изeslint-plugin-react-native
, а по опыту неиспользуемые стили в RN встречаются довольно часто. Если компонент получается слишком большим, то считать, что вынесение стилей как то с этим поможет - заблуждение, ведь компонент от этого никак не уменьшиться и не станет читаемее. Стоит задуматься о разбиении этого компонента на разные, либо вынесении части логики в утилиты или hook.Первый вариант вычислять стили - динамически в рендере, используя состояние, свойства,
useWindowDimensions
, текущую тему и т.д., и опционально завернув вuseMemo
(часто не имеет смыла). Производительность немного просядет, ведь придется создавать стили как минимум при первой отрисовке каждого компонента, а чаще всего при каждой. Также, из за подписки на размер экрана компонент будет перерисовываться на каждое изменение размера окна, а не только при смене типа верстки с широкой на узкую - например растягивание окна браузера может подлагивать, но это не самая большая проблема. Подходит для ситуаций, когда стили вычисляются из состояния и свойств, уникальных для каждого компонента, либо если вычислений совсем немного.Другой вариант - создать стили статически сразу для разных вариантов, например для светлой и темной темы -
lightStyles
иdarkStyles
, и применять в компоненте исходя из настроек системы:const styles = isDark ? darkStyles : lightStyles
. Хороший вариант, если у стилей всего одна булева зависимость, например темная / светлая тема. Минус - не подходит когда зависимостей больше. Также при старте приложения выполняется больше кода, что в теории может плохо влиять на время запуска (вроде бы Hermes такое оптимизирует, но это не точно).Оптимальный вариант - утилита кэширования для глобальных стилей. Может использоваться обычный memoizeOne, но в нем есть проблема с типизацией возвращаемых стилей и чуть больше кода, чем в приведенной далее самописной утилите. Стили вычисляются лениво, только при изменении зависимостей, например когда верстка меняется с широкой на узкую, либо при изменении темы - со светлой на темную, а компоненты переиспользуют мемоизированные стили. Из минусов - правила eslint из первого пункта будут показывать ошибку (на момент написания статьи). Подходит для стилей с глобально уникальными (singleton) зависимостями. Например размер окна обычно глобально уникален, как и текущая тема, а вот состояние компонента - нет.
Как это выглядит в коде:
// Сколько бы не было одновременно замонтировано компонентов, используюзих
// эти стили, они вычисляться только один раз, и пересчитываются только на
// изменение isSmallLayout или theme.
const getStyles = cacheStyles((isSmallLayout: boolean, theme: Theme) => {
return StyleSheet.create({
list: {
flex: 1,
backgroundColor: theme.colors.backgroundColor
},
listContentContainer: {
padding: isSmallLayout ? 8 : 16
},
})
})
cacheStyles
/**
* Возвращает функцию, которая возвращает стили для последних
* неглубоко равных (shallowly equal) аргументов.
* Eсли еще не закэшированы - создает с помощью функции styleCreator.
*/
export const cacheStyles = <
A extends unknown[],
S extends StyleSheet.NamedStyles<S> | StyleSheet.NamedStyles<any>
>(
styleCreator: (...args: A) => S
): ((...args: A) => S) => {
let lastArgs: A
let style: S
return (...args: A) => {
if (!style || !shallowEqualArrays(args, lastArgs)) {
style = StyleSheet.create(styleCreator(...args))
lastArgs = args
}
return style
}
}
const shallowEqualArrays = (
arrA: unknown[],
arrB: unknown[]
): boolean => {
if (arrA === arrB) {
return true;
}
if (!arrA || !arrB) {
return false;
}
const length = arrA.length;
if (arrB.length !== length) {
return false;
}
for (let i = 0; i < length; i += 1) {
if (arrA[i] !== arrB[i]) {
return false;
}
}
return true;
}
Использование стилей:
export const ListScreen = () => {
// SmallLayoutProvider можно добавить в корневом компоненте App, и при
// необходимости переопределять для некоторых экранов с другим значением
// c помощью withSmallLayoutContext.
const isSmallLayout = useSmallLayoutContext()
const theme = useTheme()
const styles = getStyles(isSmallLayout, theme)
return (
<FlatList
style={styles.list}
contentContainerStyle={styles.listContentContainer}
...
/>
)
}
useSmallLayoutContext
/** Возвращает меньше ли текущая ширина окна заданного в Provider порога. */
export const useSmallLayoutContext = () => {
return useContext(SmallLayoutContext)
}
export const SmallLayoutContextProvider: FC<PropsWithChildren<{ threshold?: number }>> = ({ children, threshold = 785 }) => {
return (
<SmallLayoutContext.Provider value={useIsSmallLayout(threshold)}>
{children}
</SmallLayoutContext.Provider>
)
}
export const withSmallLayoutContext = <P extends PropsWithChildren>(
Component: ComponentType<P>,
threshold?: number
): ComponentType<P> => {
const WithSmallLayoutContext: FC<P> = (props) => {
return (
<SmallLayoutContextProvider threshold={threshold}>
<Component {...props} />
</SmallLayoutContextProvider>
)
}
return WithSmallLayoutContext
}
// Хук
// Версия с useWindowDimensions перерендерит при любом изменении размера
// экрана, тогда как данная версия только при изменении значения isSmallLayout.
// Версия с useState при изменении threshold
// вызывала бы дополнительную перерисовку и возвращала бы правильное
// значение только после нее (асинхронно). Данная версия без лишних
// перерисовок и синхронная. React ¯\_(ツ)_/¯.
/** Возвращает меньше ли текущая ширина окна заданного порога. */
export const useIsSmallLayout = (threshold = 785) => {
const isSmallLayout = Dimensions.get('window').width < threshold
const lastValueRef = useRef(isSmallLayout)
lastValueRef.current = isSmallLayout
const forceUpdate = useForceUpdate()
useEffect(() => {
const subscription = Dimensions.addEventListener(
'change',
({window}) => {
const newValue = window.width < threshold
if (lastValueRef.current !== newValue) {
forceUpdate()
}
},
);
return () => subscription.remove();
}, EMPTY_ARRAY);
return isSmallLayout
}
// Утилиты
const EMPTY_ARRAY: any[] = []
const forceUpdateReducer = (i: number) => i + 1
/** Возвращает функцию для принудительного рендеринга компоненты. */
export const useForceUpdate = () => {
return useReducer(forceUpdateReducer, 0)[1]
}
Почему контекст? Если использовать хук
useIsSmallLayout
напрямую из дочерних и родительских компонентов, то могут появиться баги из за разной очередности обновления и перерисовки, когда дочерний компонент обновляется раньше родительского, либо они могут использовать разные значения threshold.Если нужно поддерживать больше размеров, можно возвращать не
boolean
, а, например, тип'small' | 'medium' | 'large'
или enum. Также можно добавить эти значения напрямую в Theme, без отдельного контекста. Адаптируйте подходы исходя из ситуации.Реализацию
useTheme
, возвращающую цветовую схему (dark / light) и палитру цветов, оставлю на читателе - она элементарна, и в общем то не всегда даже нужна. Вместо нее может быть useColorScheme или ничего, если темная тема не поддерживается. Мой совет - использовать для подобных вещейReact.Context
.
Вместо создания оберток над компонентами или дублирования кода можно рассмотреть использование утилит для задания значений по умолчанию (например, fontFamily) и генерации стилей текста, теней и т.п. Обертки увеличивают VDOM и вычисляют стили во время отрисовки, чем немного ухудшают производительность. Пример:
const getStyles = cacheStyles(({font, shadow, textShadow, colors}: Theme) => {
return {
container: {
...shadow(0, 6, 20),
padding: 8,
gap: 8,
},
label: {
...font(24, 29, '900'),
...textShadow(0, 2, 4),
color: colors.text,
},
})
})
font, shadow, textShadow
const FONT_WEIGHT_TO_FONT: {
[key in NonNullable<TextStyle['fontWeight']>]: TextStyle['fontFamily'];
} = {
normal: 'Inter-Regular',
bold: 'Inter-Bold',
// ...и т.д.
};
export const font = (
fontSize: number,
lineHeight?: number,
fontWeight: TextStyle['fontWeight'] = 'normal',
fontFamily = FONT_WEIGHT_TO_FONT[fontWeight]
): Pick<
TextStyle,
'fontSize' | 'fontFamily' | 'fontWeight' | 'lineHeight'
> => {
const style: TextStyle = {
fontSize,
fontFamily,
fontWeight,
}
if (lineHeight !== undefined) {
style.lineHeight = lineHeight
}
return style
}
// Данная утилита не обновлена под версию RN 0.76, где появилась
// поддержка boxShadow.
export const shadow = (
xOffset: number,
yOffset: number,
radius: number,
opacity: number = 0.5,
color: ViewStyle['shadowColor'] = 'black',
elevation?: number,
): Pick<
ViewStyle,
'shadowOffset' | 'shadowOpacity' | 'shadowColor' | 'shadowRadius' | 'elevation'
> => {
return isAndroid ? {
elevation: elevation ?? Math.max(Math.round(radius * 0.65), 1)
} : {
shadowOffset: { width: xOffset, height: yOffset },
shadowOpacity: opacity,
shadowColor: color,
shadowRadius: radius,
}
}
export const textShadow = (
offsetX: number,
offsetY: number,
radius: number,
color: TextStyle['textShadowColor'],
): Pick<TextStyle, 'textShadowOffset' | 'textShadowRadius' | 'textShadowColor'> => {
return {
textShadowOffset: {
height: offsetY,
width: offsetX,
},
textShadowRadius: radius,
textShadowColor: color,
}
}
Добавить данные утилиты в Theme
при необходимости - несложная задача.
3. Картинки
3.1. Web
Как известно, на мобильных устройствах нужно предоставить картинки двух размеров - с суффиксами @2x и @3x. Картинки без суффикса использовались на очень старых устройствах с до-retina экранами, и сегодня нигде не используются. Нигде, кроме веба. Да, по умолчанию веб использует картинки без суффиксов, и даже в примере от expo при инициализации проекта есть такой "баг", из за которого в вебе плохое качество картинок.
Самое простое решение - класть картинку без суффикса такого размера, который вы хотите видеть в вебе. Например, можно скопировать @3x. Если веб не поддерживается - то картинки без суффикса не нужны.
По поводу что же лучше - PNG или SVG:
PNG растровый, и куда более производительный вариант по нагрузке на процессор. Приложения для платформ Apple оптимизированы для этого формата. Ему должен отдаваться приоритет.
SVG векторный, является более доступным - люди с плохим зрением часто масштабируют браузер, а они при этом не теряют в качестве. Поддерживает анимацию. Зачастую размер файла сильно меньше в зависимости от сложности картинки. Если браузер не поддерживается, а масштабирование и анимации не нужны, то смысла использовать практически нет. Требуется сторонняя зависимость
react-native-svg
и утилита для конвертации.
3.2. Генерация кода доступа
Из документации можно понять, что импортировать картинки нужно через require(<путь>)
прямо из компонентов. Но это можно делать куда удобнее используя скрипт генерации кода доступа к ресурсам. Пример использования:
<Image source={Images.navigation.back} />
scripts/generate-assets-access-code.ts
// Генерирует код доступа для картинок в папке assets/images.
import fs from 'fs';
import path from 'path';
const ROOT_DIRECTORY = path.join(__dirname, '..');
// Генератор
const generateIndexFile = (
directory: string,
extensions: string[],
ignore?: (filename: string) => boolean,
) => {
const lines = [
`// Autogenerated by ${path.basename(__filename)}
export const ${capitalize(path.basename(directory))} = {
`,
];
appendLinesFromDirectory(lines, directory, directory, extensions, ignore);
lines.push('} as const;\n');
const destination = path.join(directory, 'index.ts');
const content = lines.join('');
fs.writeFile(destination, content, (error) =>
console.log(error ?? `${path.relative(ROOT_DIRECTORY, directory)} generated successfully!`),
);
};
// Утилиты
const appendLinesFromDirectory = (
result: string[],
rootDirectory: string,
subDirectory: string,
extensions: string[],
ignore: Parameters<typeof generateIndexFile>[2],
level = 1,
) => {
fs.readdirSync(subDirectory).forEach((basename: string) => {
const fullPath: string = path.join(subDirectory, basename);
const stat = fs.statSync(fullPath);
const { name, ext } = path.parse(basename);
const indent = ' '.repeat(level);
if (stat.isDirectory()) {
result.push(`${indent}${formatName(basename)}: {\n`);
appendLinesFromDirectory(result, rootDirectory, fullPath, extensions, ignore, level + 1);
result.push(`${indent}},\n`);
return;
}
if (stat.isFile() && extensions.includes(ext) && (!ignore || !ignore(name))) {
const propName = formatName(name);
const relativePath = path.relative(rootDirectory, fullPath);
result.push(`${indent}${propName}: require('./${relativePath}'),\n`);
}
});
};
const formatName = (name: string) => (isCapitalized(name) ? name : camelCase(name));
const isCapitalized = (input: string) => input.toUpperCase() === input;
const camelCase = (input: string) =>
input
.split('-')
.map((x, i) => (i ? capitalize(x) : x))
.join('');
const capitalize = (str: string) => str[0].toUpperCase() + str.slice(1).toLowerCase()
// Генерируем код
generateIndexFile(
path.join(ROOT_DIRECTORY, 'assets', 'images'),
['.png', '.jpg'],
(filename) => filename.endsWith('@2x') || filename.endsWith('@3x'), // Используем только файлы без суффиксов
);
Этот же код можно использовать для генерации кода доступа к любым ресурсам, например к анимациям Lottie.
Итоги
Как мы видим, адаптивная верстка и организация стилей в React Native без проблем делается встроенными средствами и несколькими простыми функциями. Любые библиотеки для этой задачи предлагаю считать говнокодом неудачным архитектурным решением. Также хочу отметить, что данные подходы успешно применялись на практике - был опыт серьезного рефакторинга 7-летнего проекта для трех платформ, написанного в стиле "как не надо", после которого он без проблем прошел аудит доступности от компании Deque, включающий проверку адаптивности интерфейса.
Комментарии (11)
Mox
21.11.2024 16:31По поводу размеров отступов - мы пришли к тому, что лучше делать таки по сетке в 4 пикселя.
Дело не в том, что мы что-то собираемся поменять потом, это техника безопасности при ошибках в макетах - то есть, если мы видим какой-то нестандартный отступ между - сразу понятно - это баг
Ну и дальше можно стандартизовать еще какое-то количество отступов семантически , что удобно в продуктовой разрабокте, когда надо постоянно изменять фичи.gen1lee Автор
21.11.2024 16:31Очень часто бывает, что чтобы сделать pixel perfect интерфейс, отступы в коде должны немного отличаться от макетов из за мелких отличий того или иного встроенного компонента, шрифта или экспортированной картинки.
Также, не помню чтоб была проблема нестандартных отступов там где не нужно - действительно ли она существует чтоб ее решать?
Если же речь идет о 100% одинаковом отступе для компонентов на одном экране, например схожие но разные ячейки, то конечно иногда имеет смысл вынести его в константу, а то и экспортировать общие стили, сам так много раз делал. Добавлю это в статью.
Но спасибо за мнение.Mox
21.11.2024 16:31Тут скорее речь о кросс-проектной семантики отступов - чтобы определенные типы отступов были всегда одинаковые, или там - кастомные заданные варианты line height к определенным размерам шрифта (правда это мы решили просто своим компонентом для текста с типированными параметрами, а не константами).
То есть это про общую консистентность дизайна в проекте.
Кстати - а чем вы пользуетесь для Pixel Perfect на RN? Я одно время просил дизайнеров в Zeplin выгружать, чтобы был полупрозрачный слой, но чет не прижилась практика.gen1lee Автор
21.11.2024 16:31Когда то давно помню накладывал полупрозрачные картинки поверх симуляторов - для мака какой то плагин был даже чтобы для любого окна можно было поменять прозрачность.
Но последнее время если прям точно надо (что бывает редко), то делаю скриншот с симулятора / эмулятора и в Preview замеряю отступ с помощью выделения. По возможности стараюсь делегировать это на отдел тестирования.
kirill3333
21.11.2024 16:31Добавлю ссылочку на более свежий бенчмарк и версии библиотек https://github.com/divineniiquaye/react-native-style-libraries-benchmark
Mox
Cпасибо за статью
А почему решили писать собственный мемоизатор для функции создания стилей?
Мы просто пришли к такому коду, ну и в целом стараемся юзать встроенную мемоизацию.
Ну и использование вида
gen1lee Автор
Я в статье как раз пишу в пункте 2.2, что в вашем случае стили создаются каждым компонентом, что плохо влияет на производительность. В моем случае они переиспользуются всеми компонентами, и нет дополнительного
useMemo
. Также в вашем случае я вижу придется еще иcreator
вuseCallback
заворачивать, либо выносить из компонента.Mox
Да, у нас стили вне компонента и в отдельном файле. Мы стараемся чтобы каждый файл был максимально короткий и читаемый.
gen1lee Автор
Про разные файлы для стилей и читаемость тоже писал в пункте 2.1.