Skip to content
样式与布局

样式与布局

RN 的样式系统是写给 JS 的对象,布局引擎是 Yoga——一个 flexbox 子集加少量原生扩展。它借了 CSS 的语法外壳,但运行规则完全不同。对已经会 Web 的读者,这一章的重点不是"RN 有哪些样式属性",而是把你脑子里的 CSS 心智模型做一次有意识的替换:哪些直觉能用,哪些必须卸载。

基线 RN 0.88,前置概念见 01-核心概念与架构.md。


StyleSheet.create 与内联对象

样式本质就是普通 JS 对象,直接内联也合法:

<View style={{ flex: 1, backgroundColor: '#fff' }} />

但生产代码推荐把样式收进 StyleSheet.create:

const styles = StyleSheet.create({
  page: { flex: 1, backgroundColor: '#fff' },
  title: { fontSize: 18, color: '#111827' },
});

用它的理由有三个,都不是"性能玄学"而是工程性的:开发期校验——非法的属性名和取值(比如把 flexDirection 写成 flex-direction、column 写成 colum)会在开发模式下给出提示,内联对象没有这层检查;引用稳定——create 返回的对象是固定的引用,传给组件不会像内联字面量那样每次渲染都是新对象,减少下游 memo 失效;组织性——样式和 JSX 分离,同文件顶部集中管理,改名有 IDE 重构兜底。

多条件组合样式用数组,后面的覆盖前面的:

<View style={[styles.card, isActive && styles.cardActive, customStyle]} />

false/undefined 在数组里会被跳过,isActive && styles.cardActive 是标准写法。较新版本的 RN 里 StyleSheet.create 本身也接受数组(StyleSheet.create([base, extend])),效果等价于拍平合并;拿不准就坚持"数组写在 style 属性上"这个老姿势,任何版本都对。

另一个实用小件:StyleSheet.hairlineWidth——跨设备的一物理像素细线(分隔线),比写 1 在各密度屏上的观感一致得多。


与 Web CSS 的心智差异清单

这是本章最重要的一节。逐条对照,把不能带的直觉卸载掉。

flexDirection 默认是 column

RN 里所有容器的默认主轴是竖的。Web 的 div 默认块级纵向流 + flex 默认 row,RN 只有 column。横向排一行,必须显式声明:

<View style={{ flexDirection: 'row' }}>
  <Avatar />
  <NameBlock style={{ flex: 1 }} />
</View>

新手第一天最常写的 bug:两个 View 想左右排,结果上下叠——忘了 row。

样式没有继承,唯一例外是嵌套 Text

CSS 里字体、颜色会从祖先流到后代,RN 不会:每个 View 的样式只作用于自己,子节点从零开始。唯一的继承发生在 Text 嵌 Text 时——内层继承外层的字体类属性(字号、颜色、字重、字体族、行高等),布局类不继承。所以页面上每个 Text 都要自带完整字体声明,这正是设计 token / 主题样式表(见下文 useColorScheme 一节)在 RN 里比 Web 更刚需的原因。

flex: 1 的含义

RN 里 flex: 1 等价于 flex-grow: 1; flex-shrink: 1; flex-basis: 0——“把剩余空间按份数分我”。三个兄弟各 flex: 1 就三等分;经典用法是"中间内容撑满、头尾包内容"的三段式布局。flex 只给一个数字时和 Web 语义一致;要精确控制可以拆开写 flexGrow / flexShrink / flexBasis。

百分比:从 0.75 开始支持百分比布局值

RN 历史上宽高不接受百分比(只有 margin/padding 等部分属性可以),布局全靠 flex 比例,这是很多 Web 转 RN 的人第一个月最不适的地方。从 0.75 开始支持百分比布局值,width: '50%' 现在是合法写法;但社区惯性仍是 flex 优先——比例分配用 flex,绝对约束(半屏图、固定卡片)才用百分比。

没有媒体查询

CSS 的 @media (min-width: 768px) 在这里不存在,断点逻辑用 JS 实现:useWindowDimensions 拿宽高做条件样式,见响应式一节。

数值单位是密度无关像素,不是 px

样式里的裸数字单位是逻辑像素(dp/pt),RN 按设备像素密度自动换算到物理像素。width: 100 在 2x 屏上是 200 物理像素。PixelRatio.get() 读当前倍率,PixelRatio.roundToNearestPixel(n) 避免非整倍换算的模糊——1px 边框、细线、发丝级分隔线都要过这一道。

zIndex 靠数组顺序更可靠

RN 没有独立的图层概念。iOS 上 zIndex 可用;但历史上两平台的堆叠行为有差异,最稳的写法是用 JSX 数组顺序控制——后渲染的盖在先渲染的上面,配合绝对定位。需要复杂堆叠(弹层、拖拽幽灵)时,把它提到组件树更靠后的位置,或干脆用 Modal/导航栈解决。

overflow 默认值平台不一致

iOS 默认 visible、Android 默认 hidden。凡是子节点要超出父框(悬浮角标、溢出阴影),两边都显式声明 overflow: 'visible',否则只在 iPhone 上能看到。

没有选择器、伪类、级联

不能按标签/类/后代选样式,所有样式都是组件上逐个传的 props。hover/focus 这类状态反馈走 Pressable 的 style 回调(见 02-核心组件.md),不靠 CSS 伪类。

你在 Web 的习惯 RN 的替代
display: flex; flex-direction: row 默认 row 默认 column,横向显式 flexDirection: 'row'
继承字体/颜色 每个 Text 自带;只有嵌套 Text 继承字体类
width: 50% 0.75 起可用百分比;惯用 flex: 1 比例
@media useWindowDimensions 条件样式
:hover / :active Pressable style={({pressed}) => ...}
px dp/pt(逻辑像素),PixelRatio 换算
z-index 数组顺序为主,zIndex 为辅(平台有差异)

Flexbox 实战:三个必会布局

案例一:顶栏 + 滚动区 + 底部输入栏

聊天/评论/私信页的标准骨架。要点:头尾包内容、中间 flex: 1 吃掉剩余;输入栏贴底部时要处理安全区(edge-to-edge 下底部手势条会遮住输入框,见下文 Edge-to-Edge 一节与 02-核心组件.md)。

// App.tsx
import React, { useState } from 'react';
import {
  Pressable, ScrollView, Text, TextInput, View,
} from 'react-native';
import {
  SafeAreaProvider, SafeAreaView, useSafeAreaInsets,
} from 'react-native-safe-area-context';

function ChatScreen() {
  const insets = useSafeAreaInsets();
  const [draft, setDraft] = useState('');
  return (
    <View style={{ flex: 1, backgroundColor: '#F6F7F9' }}>
      {/* 顶栏 */}
      <View style={styles.header}>
        <Text style={styles.headerTitle}>产品支持</Text>
      </View>
      {/* 滚动区:flex:1 吃掉中间剩余空间 */}
      <ScrollView
        style={{ flex: 1 }}
        contentContainerStyle={{ padding: 12, gap: 8 }}
      >
        {MESSAGES.map((m) => (
          <View
            key={m.id}
            style={[
              styles.bubble,
              m.mine ? styles.bubbleMine : styles.bubblePeer,
            ]}
          >
            <Text style={{ color: m.mine ? '#fff' : '#111827' }}>{m.text}</Text>
          </View>
        ))}
      </ScrollView>
      {/* 底部输入栏:用 insets.bottom 顶起手势条高度 */}
      <View style={[styles.composer, { paddingBottom: insets.bottom + 8 }]}>
        <TextInput
          style={styles.input}
          value={draft}
          onChangeText={setDraft}
          placeholder="说点什么…"
        />
        <Pressable style={({ pressed }) => [styles.sendBtn, pressed && { opacity: 0.7 }]}>
          <Text style={{ color: '#fff', fontWeight: '600' }}>发送</Text>
        </Pressable>
      </View>
    </View>
  );
}

const MESSAGES = Array.from({ length: 30 }, (_, i) => ({
  id: `m-${i}`,
  text: `消息 ${i + 1}`,
  mine: i % 3 === 0,
}));

export default function App() {
  return (
    <SafeAreaProvider>
      <SafeAreaView edges={['top']} style={{ flex: 1 }}>
        <ChatScreen />
      </SafeAreaView>
    </SafeAreaProvider>
  );
}

const styles = StyleSheet.create({
  header: {
    height: 48,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: '#fff',
    borderBottomWidth: StyleSheet.hairlineWidth,
    borderBottomColor: '#E5E7EB',
  },
  headerTitle: { fontSize: 16, fontWeight: '600', color: '#111827' },
  bubble: {
    maxWidth: '80%',
    padding: 10,
    borderRadius: 12,
    alignSelf: 'flex-start',
  },
  bubbleMine: { backgroundColor: '#2563EB', alignSelf: 'flex-end' },
  bubblePeer: { backgroundColor: '#fff' },
  composer: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 8,
    paddingHorizontal: 12,
    paddingTop: 8,
    backgroundColor: '#fff',
    borderTopWidth: StyleSheet.hairlineWidth,
    borderTopColor: '#E5E7EB',
  },
  input: {
    flex: 1,
    borderWidth: 1,
    borderColor: '#D1D5DB',
    borderRadius: 20,
    paddingHorizontal: 14,
    paddingVertical: 8,
    fontSize: 16,
  },
  sendBtn: {
    backgroundColor: '#2563EB',
    paddingHorizontal: 16,
    paddingVertical: 10,
    borderRadius: 20,
  },
});

案例二:居中的空状态

主轴和侧轴各管一次居中,justifyContent 管主轴(默认 column → 纵向),alignItems 管横向:

<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
  <View style={{ width: 64, height: 64, borderRadius: 32, backgroundColor: '#E5E7EB' }} />
  <Text style={{ fontSize: 16, color: '#6B7280', marginTop: 12 }}>
    暂无数据
  </Text>
  <Text style={{ fontSize: 13, color: '#9CA3AF', marginTop: 4 }}>
    下拉试试刷新
  </Text>
</View>

这里"暂无数据"必须包在 Text 里——裸字符串直接红屏(见 02-核心组件.md)。多行空态之间用 gap(较新版本容器属性,等价于子项 margin 手调,能少写很多间距数学)。

案例三:左固定右自适应的两列卡片

经典图文行:左边封面固定宽,右边文字区吃掉剩余,标题超长省略号收尾。

<Pressable style={styles.card} onPress={open}>
  <Image source={{ uri: item.cover }} style={styles.cover} />
  <View style={styles.info}>
    <Text style={styles.title} numberOfLines={2} ellipsizeMode="tail">
      {item.title}
    </Text>
    <View style={styles.metaRow}>
      <Text style={styles.price}>¥{item.price}</Text>
      <Text style={styles.sales}>{item.sales} 人付款</Text>
    </View>
  </View>
</Pressable>

const styles = StyleSheet.create({
  card: {
    flexDirection: 'row',
    padding: 12,
    gap: 12,
    backgroundColor: '#fff',
    borderRadius: 10,
  },
  cover: { width: 96, height: 96, borderRadius: 8, backgroundColor: '#F3F4F6' },
  info: { flex: 1, justifyContent: 'space-between' },
  title: { fontSize: 15, color: '#111827', lineHeight: 21 },
  metaRow: { flexDirection: 'row', alignItems: 'baseline', gap: 8 },
  price: { fontSize: 16, color: '#DC2626', fontWeight: '700' },
  sales: { fontSize: 12, color: '#9CA3AF' },
});

要点拆解:cover 定宽定高不参与伸缩;info: { flex: 1 } 吃掉行内剩余宽度;标题给 numberOfLines 后在 flex 容器里才能正确截断——不给固定宽度约束的 Text 在 row 里可能把右侧挤出去。


响应式:useWindowDimensions 与 PixelRatio

Dimensions.get('window') 是一次性快照——模块加载时求值,屏幕旋转/分屏折叠不会更新,写在模块顶层的 const width = Dimensions.get('window').width 在旋转后就是错值。日常一律用 hook:

import { useWindowDimensions, StyleSheet, View } from 'react-native';

function Grid() {
  const { width } = useWindowDimensions();
  const columns = width >= 900 ? 4 : width >= 600 ? 3 : 2;
  const cell = (width - 32 - 12 * (columns - 1)) / columns;
  return (
    <View style={[styles.grid, { gap: 12 }]}>
      {/* 每个 cell 宽 cell 值,按 columns 换行 */}
    </View>
  );
}

旋转/折叠屏尺寸变化时组件自动重渲染,这就是"没有媒体查询"时代自己实现断点的标准姿势。

PixelRatio 处理像素密度:边框防糊用 PixelRatio.roundToNearestPixel(1);按倍率挑资源用 PixelRatio.get() >= 3 ? '@3x' : ...(多倍图目录命名则 RN 自动处理,见 02-核心组件.md)。平板分栏(一侧列表一侧详情)在 RN 里就是监听宽度切布局的普通条件渲染,没有专门 API。


深色模式

RN 内置三件套:useColorScheme() hook(返回 'light' | 'dark' | null,跟随系统实时变化)、Appearance 模块(手动监听)、以及各组件样式自己写。

把主题做成 StyleSheet:useTheme 模式

因为样式没有继承,深色模式的标准解法是集中定义两套语义 token,组件只引用语义名、不写死颜色:

// App.tsx
import React, { useMemo } from 'react';
import {
  Text, useColorScheme, View, StyleSheet, Pressable,
} from 'react-native';

const light = {
  bg: '#FFFFFF',
  surface: '#F6F7F9',
  text: '#111827',
  textMuted: '#6B7280',
  primary: '#2563EB',
  border: '#E5E7EB',
};

const dark = {
  bg: '#0B0F19',
  surface: '#151B2B',
  text: '#F3F4F6',
  textMuted: '#9CA3AF',
  primary: '#3B82F6',
  border: '#273043',
};

function useTheme() {
  const scheme = useColorScheme();
  return useMemo(() => {
    const t = scheme === 'dark' ? dark : light; // 语义 token:组件层只引用名字
    return {
      tokens: t,
      ...StyleSheet.create({
        page: { flex: 1, backgroundColor: t.bg },
        card: {
          backgroundColor: t.surface, borderColor: t.border,
          borderWidth: StyleSheet.hairlineWidth, borderRadius: 12, padding: 16,
        },
        title: { color: t.text, fontSize: 16 },
        muted: { color: t.textMuted, fontSize: 13 },
        primaryText: { color: '#fff', fontSize: 15, fontWeight: '600' },
        primaryBtn: {
          backgroundColor: t.primary, paddingVertical: 10,
          borderRadius: 8, alignItems: 'center',
        },
      }),
    };
  }, [scheme]);
}

export default function App() {
  const { card, title, muted, primaryBtn, primaryText, page } = useTheme();
  return (
    <View style={page}>
      <View style={card}>
        <Text style={title}>账户余额</Text>
        <Text style={muted}>最后同步:刚刚</Text>
        <View style={styles.spacer} />
        <Pressable style={primaryBtn}>
          <Text style={primaryText}>提现</Text>
        </Pressable>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({ spacer: { height: 16 } });

切换是瞬时的:useColorScheme 变化 → hook 重算 → 所有引用它的组件重渲染。工程上更常见的变体是把两套 token 放 Context,样式表改成 (tokens) => StyleSheet.create({...}) 的工厂函数。

不用 hook 的手动监听

在非组件环境(比如启动早期设置原生配置)要跟随系统,用 Appearance:

import { Appearance, ColorSchemeName } from 'react-native';

const sub = Appearance.addChangeListener(({ colorScheme }) => {
  applyNativeChrome(colorScheme); // 状态栏样式、原生 TabBar 颜色等
});
// 记得 sub.remove()

图片双资源:dark/ 目录约定

位图不会自动反色,深色模式需要另一套图。约定 assets 目录按主题分套,代码里按 scheme 挑 require:

assets/
  icons/
    home.png
    home@2x.png
    dark/
      home.png
      home@2x.png
const iconLight = require('../assets/icons/home.png');
const iconDark = require('../assets/icons/dark/home.png');
const source = scheme === 'dark' ? iconDark : iconLight;
<Image source={source} style={styles.icon} />

注意 require 的两个路径都必须是静态字面量(Metro 编译期解析),三元选择放在运行时没问题。图标量大时优先考虑 SVG 方案(react-native-svg,颜色跟着 token 走,天然免双资源)。


平台差异处理

Platform.OS vs Platform.select

单个值不同用 Platform.OS 判断:

const tabBarHeight = Platform.OS === 'ios' ? 83 : 56;

样式/配置整体分叉用 Platform.select,可读性和意图更清楚:

const styles = StyleSheet.create({
  header: Platform.select({
    ios: { shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 6 },
    android: { elevation: 4 },
    default: { borderBottomWidth: 1, borderColor: '#eee' },
  }),
});

.ios.tsx / .android.tsx:Metro 的后缀解析

同名组件要彻底走两套实现时,不需要运行时判断——Metro 解析 ./Button 时,iOS bundle 会优先命中 Button.ios.tsx,安卓命中 Button.android.tsx,都没有才回退 Button.tsx:

components/
  Button.tsx          // 共享接口/默认实现
  Button.ios.tsx      // 仅 iOS 构建被打进包
  Button.android.tsx

组件对外仍是 import { Button } from './Button',平台差异在构建层就切干净。注意:这是 Metro 打包期行为,运行时判断(Expo Go 这类共用 bundle 的场景不会按文件名分流)。

状态栏的两套 API

内置 StatusBar 组件能控制 iOS/安卓的状态栏样式,但安卓上历史上坑多(透明、覆盖、厂商 ROM 差异)。Expo 项目推荐用 expo-status-bar——同一套声明式接口抹平平台差异,RN 官方文档对安卓状态栏的建议也倾向这层封装:

import { StatusBar } from 'expo-status-bar';

<StatusBar style="auto" />  // auto 跟随系统深浅色,light/dark 显式指定

两个不要混用:同一屏里内置 StatusBar 和 expo-status-bar 互相打架。非 Expo 项目用内置 StatusBar 也完全够用。


Edge-to-Edge:从"特例"变成"默认"

Android 15 起,应用强制进入 edge-to-edge 模式——内容必须绘制到状态栏和系统导航条后面,没有"退出选项"可给。RN 把适配做进了框架:0.86 的重点就是 Edge-to-Edge 适配,0.88 的 bare 模板安卓侧默认 edgeToEdgeEnabled=true(模板生成的 android/gradle.properties 里可见)。

这带来的范式变化是:

  • statusBarTranslucent 这类"半屏遮罩"时代的开关正式成为历史。以前靠给状态栏留纯色条、或 translucent 打补丁;现在你的内容天然就延伸到屏幕物理边缘。
  • react-native-safe-area-context 从"iOS 刘海适配包"升级为两个平台的必选项:安卓的手势条、导航区避让全部通过 useSafeAreaInsets() 拿到的 insets 处理,写法见 02-核心组件.md。
  • 状态栏/导航栏图标颜色的可读性要自己负责:内容铺到状态栏后面后,深色壁纸配深色文字会"看不见",用 expo-status-bar 的 style="auto" 或按 scheme 显式切换 barStyle。

心智总结成一句话:以前是"安全区靠组件框住",现在是"全屏绘制 + insets 当内边距"。所有贴边 UI(底部输入栏、全屏图)按案例一的 insets.bottom 处理,就符合新范式。


阴影:iOS shadow* 与 Android elevation

这是两平台观感差异最大的属性族,没有之一。

iOS:shadow* 四件套

iOS 走的是 CSS 风格的独立属性,且有一个著名规则:透明背景的视图不产生阴影——shadowColor 要能渲染,视图必须有非透明背景色或内容,常见翻车是"设了阴影但 backgroundColor 是 none,阴影消失"。

const cardIos = {
  backgroundColor: '#fff',        // 必须非透明
  shadowColor: '#000',
  shadowOpacity: 0.12,
  shadowRadius: 8,
  shadowOffset: { width: 0, height: 4 },
};

Android:elevation

安卓历史上用 elevation(Z 轴高度,单位 dp)表达阴影——系统统一计算光源与投影,你不能精确控制颜色、偏移、模糊半径,观感是"安卓味的灰影"。卡片配方按惯例是 elevation 36 对应轻浮起、812 对应弹层:

const cardAndroid = {
  backgroundColor: '#fff',
  elevation: 4,
};

RN 较新版本支持了 boxShadow 样式属性(安卓侧也能输出接近 iOS 可控阴影的观感,方向与半径可调,属于 0.77 前后那轮"New Styling Features"的延续),需要两平台阴影严格一致时优先它;但要兼容存量旧版本时,双写 shadow*(iOS)+ elevation(安卓)仍是事实标准。

两平台统一卡片写法

import React from 'react';
import { Platform, StyleSheet, Text, View } from 'react-native';

export default function App() {
  return (
    <View style={{ flex: 1, backgroundColor: '#F6F7F9', padding: 24 }}>
      <View style={styles.card}>
        <Text style={{ fontSize: 16, color: '#111827' }}>带阴影的卡片</Text>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  card: {
    padding: 20,
    borderRadius: 14,
    backgroundColor: '#fff',
    ...Platform.select({
      ios: {
        shadowColor: '#0F172A',
        shadowOpacity: 0.12,
        shadowRadius: 8,
        shadowOffset: { width: 0, height: 4 },
      },
      android: { elevation: 4 },
    }),
  },
});

两个补充规则:overflow: 'hidden'(或 visible=false)会裁掉阴影,带阴影容器别设 hidden;圆角很大时 iOS 阴影跟着圆角走、安卓 elevation 在旧版本上是"矩形投影"感更重,测试时留意。


布局调试

最便宜的调试法是临时描边:把可疑容器加红色 outline(较新版本 RN 支持 outline* 样式系列;不放心兼容性就直接用 borderWidth),一眼看清盒子在哪、多大:

<View style={{ ...styles.container, borderWidth: 1, borderColor: 'red' }}>

配合 React DevTools 选中节点检查其宿主视图尺寸(Layout 面板直接显示宽高)——不用写代码就能看"这个 View 到底有没有被子节点撑开"。更多调试手段在 10-调试测试与性能.md。


尺寸测量:onLayout 与 measureInWindow

onLayout:只在"自己的布局变了"时响

onLayout 在视图完成布局时触发,之后仅当该视图自身尺寸/位置变化时再次触发——父级 ScrollView 滚动、祖先位移都不会让它重报,这是"为什么我的 onLayout 不更新"的头号原因。另外它可能在挂载初期报一次零尺寸(尤其视图不在可视区),写订阅逻辑要防 width === 0。

<View
  onLayout={(e) => {
    const { x, y, width, height } = e.nativeEvent.layout;
    // x/y 相对于父视图;width/height 是逻辑像素
    setBox({ width, height });
  }}
/>

measureInWindow:要的是相对窗口的绝对坐标

弹层锚定、气泡指向、拖拽吸附这类"算屏幕坐标"的场景用 ref 上的 measureInWindow,回调形式(异步):

import React, { useRef } from 'react';
import { Text, View, ViewStyle } from 'react-native';

export default function App() {
  const anchor = useRef<View>(null);
  const showMenu = () => {
    anchor.current?.measureInWindow((x, y, width, height) => {
      openMenuAt({ x, y: y + height, width }); // 相对窗口的坐标
    });
  };
  return (
    <View style={{ padding: 24 }}>
      <View ref={anchor} onLayout={() => undefined}>
        <Text onPress={showMenu} style={{ fontSize: 16 }}>更多</Text>
      </View>
    </View>
  );
}

坑位清单:

  • 坐标系不同——onLayout 给的 x/y 相对父视图,measureInWindow 给的是相对窗口的绝对坐标,混用会偏移。
  • 两者都是异步的,拿到值时视图可能已卸载;用 useRef 判空(anchor.current?.)。
  • measure 系列在视图尚未完成首次布局时调用会拿到空值——放在 onLayout 之后或交互回调里调,别放 useEffect 挂载瞬间硬调。
  • edge-to-edge 与 Modal 内测量:坐标系仍相对窗口,但别忘 y 已含状态栏区域,落点换算按当前平台行为实测。

实战建议

样式方案按"内置打底、按需增强"来选:

  • 默认就用 StyleSheet.create + 语义 token。RN 样式本身就是 JS,中大型项目的核心问题不是"要不要框架"而是"颜色/字号/间距从哪来"——把两套 token(light/dark)集中成对象,组件层只引用语义名,这套手工活足够撑到几十人规模,别过早引入方案负担。
  • 需要 Web 式类名手感时再看社区方案:Tailwind 生态的 NativeWind/tailwind-rn 路线,以及 Unistyles 这类带原生样式求值的库,各有取舍(运行时性能、静态提取、与 Reanimated 的协作方式),选型前先在真实列表页做压力对比,版本迭代快,以当前生态状态为准。
  • 布局习惯:比例用 flex,绝对约束才用百分比(0.75 起可用);两列"固定 + 自适应"永远是定宽 + flex: 1;跨端差异大的一律 Platform.select 或 .ios/.android 文件分叉,不在组件里写三元堆样式。
  • 深色模式从第一天就做:token 层做对之后,深色只是换一套常量,事后补的成本远高于先定义语义色。
  • edge-to-edge 别再对抗:新模板默认铺满全屏,insets 是唯一正确答案;遇到"底部被手势条吃掉一截"的反馈,直接检查 insets.bottom。
  • 阴影要平台双写测试:同一组数值 iOS/安卓观感必然不同,设计稿验收时两台真机都要过,别只信截图。