React Native 字体探究·补充

本文继续讨论 <Text> 的原生实现和 React Native 如何设置字体。

<Text> Android 实现

Android 的代码类之间的继承和调用关系更清晰。参照 Android Native UI Components,直接寻找 <Text> 对应的 ViewManager 类:ReactTextViewManager

ReactAndroid/src/main/java/com/facebook/react/views/text 目录下主要有以下 ViewManager

  • RCTText: ReactTextViewManager 管理 <Text> 节点
  • RCTVirtualText: ReactVirtualTextViewManager 管理虚拟文本节点
  • RCTRawText: ReactRawTextManager 管理虚拟节点中使用的原始文本节点(类似 DOM 中的 textContent

ReactTextViewManager 负责管理 ReactTextView 实例(对应 UI 视图) 和 ReactTextShadowNode 示例(对应 Shadow Tree)。React Native 会在 Shadow Thread 维护一个 Shadow Tree 描述页面结构样式,同时计算页面布局,通知 Main Thread 进行渲染。

重点关注 ReactTextShadowNode。React Native 文档 Nested text 提到过 <Text> 组件在 Android 上通过 SpannableString 实现。在 ReactTextShadowNode 的代码中可以看出这点,从 Shadow 节点创建 SpannableStringBuilder 实例:

// ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java

  @Override
  public void onBeforeLayout(NativeViewHierarchyOptimizer nativeViewHierarchyOptimizer) {
    mPreparedSpannableText =
        spannedFromShadowNode(
            this,
            /* text (e.g. from `value` prop): */ null,
            /* supportsInlineViews: */ true,
            nativeViewHierarchyOptimizer);
    markUpdated();
  }

ReactTextShadowNode 继承自 ReactBaseTextShadowNodespannedFromShadowNode() 定义在 ReactBaseTextShadowNode 中,精简后的代码如下:

// ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java

  protected Spannable spannedFromShadowNode(
      ReactBaseTextShadowNode textShadowNode,
      String text,
      boolean supportsInlineViews,
      NativeViewHierarchyOptimizer nativeViewHierarchyOptimizer) {

    SpannableStringBuilder sb = new SpannableStringBuilder();
    List<SetSpanOperation> ops = new ArrayList<>();

    buildSpannedFromShadowNode(textShadowNode, sb, ops, null, supportsInlineViews, inlineViews, 0);

    int priority = 0;
    for (SetSpanOperation op : ops) {
      op.execute(sb, priority);
      priority++;
    }

    return sb;
  }

  private static void buildSpannedFromShadowNode(
      ReactBaseTextShadowNode textShadowNode,
      SpannableStringBuilder sb,
      List<SetSpanOperation> ops,
      TextAttributes parentTextAttributes,
      boolean supportsInlineViews,
      Map<Integer, ReactShadowNode> inlineViews,
      int start) {

    TextAttributes textAttributes;
    if (parentTextAttributes != null) {
      textAttributes = parentTextAttributes.applyChild(textShadowNode.mTextAttributes);
    } else {
      textAttributes = textShadowNode.mTextAttributes;
    }

    for (int i = 0, length = textShadowNode.getChildCount(); i < length; i++) {
      ReactShadowNode child = textShadowNode.getChildAt(i);

      if (child instanceof ReactRawTextShadowNode) {
        sb.append(...);
      } else if (child instanceof ReactBaseTextShadowNode) {
        buildSpannedFromShadowNode(...);
      } else if (child instanceof ReactTextInlineImageShadowNode) {
        sb.append(...);
        ops.add(new SetSpanOperation(...));
      } else if (supportsInlineViews) {
        sb.append(...);
        ops.add(new SetSpanOperation(...));
      } else {
        throw new IllegalViewOperationException(
            "Unexpected view type nested under a <Text> or <TextInput> node: " + child.getClass());
      }
    }

    int end = sb.length();
    if (end >= start) {
      if (textShadowNode.mFontStyle != UNSET
          || textShadowNode.mFontWeight != UNSET
          || textShadowNode.mFontFamily != null) {
        ops.add(
            new SetSpanOperation(
                start,
                end,
                new CustomStyleSpan(
                    textShadowNode.mFontStyle,
                    textShadowNode.mFontWeight,
                    textShadowNode.mFontFeatureSettings,
                    textShadowNode.mFontFamily,
                    textShadowNode.getThemedContext().getAssets())));
      }
    }
  }

<Text> 组成的 Shadow 节点树处理成 Android 的 SpannableStringBuilder,每一块有单独样式的文本都是一个独立的 CustomStyleSpan

Android 开发文档 Spans 中的一段代码:

SpannableString spannable = SpannableString("Text is spantastic!");
spannable.setSpan(
    new ForegroundColorSpan(Color.RED),
    8, 12,
    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
);
spannable.setSpan(
    new StyleSpan(Typeface.BOLD),
    8, spannable.length(),
    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
);

如果转写成 React Native,相当于:

<Text>
  Text is
  <Text style={{ color: 'red' }}> span</Text>
  <Text style={{ fontWeight: 'bold' }}>tastic!</Text>
</Text>

除视图组件以外,跟字体相关的剩下工具类 ReactTypefaceUtils 和用于加载、创建字体的 ReactFontManager,和 iOS 代码类似,没有特别复杂的字体设置逻辑,都是将开发者设置的值一路传递到 Android 代码。

原生怎么设置字体

既然 iOS 和 Android 都没对字体做特殊处理,那么实际渲染效果就取决于原生的实现了。

iOS 上怎么创建字体

先看 iOS 的文档,见 UIFont https://developer.apple.com/documentation/uikit/uifont

iOS 平台的 UIFont 有两个字体属性,对应字体族名称和字体名称:

  • familyName: The font family name. A family name is a name such as Times New Roman that identifies one or more specific fonts.
  • fontName: The font face name. The font name is a name such as HelveticaBold that incorporates the family name and any specific style information for the font.

macOS 的 NSFont 文档对字体描述更详细,可以对照着看:

  • displayName: The name of the font, including family and face names, to use when displaying the font information to the user.
  • familyName: The family name of the font—for example, “Times” or “Helvetica.”
  • fontName: The full name of the font, as used in PostScript language code—for example, “Times-Roman” or “Helvetica-Oblique.”

实际新建 UIFont 对象时,必须传入 fontName,而不是 familyName

// https://developer.apple.com/documentation/uikit/uifont/1619041-fontwithname?language=objc
UIFont *font = [UIFont fontWithName:@"PingFangSC-Regular" size:14];

同时 UIFont 提供了 fontNamesForFamilyName 方法,可以获取 familyName 对应的所有 fontName

// https://developer.apple.com/documentation/uikit/uifont/1619023-fontnamesforfamilyname?language=objc
NSArray<NSString *> *fontNames = [UIFont fontNamesForFamilyName:@"PingFang SC"];

对照 React Native 代码,可以发现,React Native 会检查 family 相应的字体名称列表,能查到数据,则进一步根据 styleweight 匹配字体,不能查到,则把 family 当字体名称,如果创建失败,再回退到系统字体。

// react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/RCTFontUtils.mm
  UIFont *font;
  if ([fontProperties.family isEqualToString:defaultFontProperties.family]) {
    // Handle system font as special case. This ensures that we preserve
    // the specific metrics of the standard system font as closely as possible.
    font = RCTDefaultFontWithFontProperties(fontProperties);
  } else {
    NSArray<NSString *> *fontNames = [UIFont fontNamesForFamilyName:fontProperties.family];

    if (fontNames.count == 0) {
      // Gracefully handle being given a font name rather than font family, for
      // example: "Helvetica Light Oblique" rather than just "Helvetica".
      font = [UIFont fontWithName:fontProperties.family size:effectiveFontSize];

      if (!font) {
        // Failback to system font.
        font = [UIFont systemFontOfSize:effectiveFontSize weight:fontProperties.weight];
      }
    } else {
      // Get the closest font that matches the given weight for the fontFamily
      CGFloat closestWeight = INFINITY;
      for (NSString *name in fontNames) {
        UIFont *fontMatch = [UIFont fontWithName:name size:effectiveFontSize];

        if (RCTGetFontStyle(fontMatch) != fontProperties.style) {
          continue;
        }

        CGFloat testWeight = RCTGetFontWeight(fontMatch);
        if (ABS(testWeight - fontProperties.weight) < ABS(closestWeight - fontProperties.weight)) {
          font = fontMatch;
          closestWeight = testWeight;
        }
      }

      if (!font) {
        // If we still don't have a match at least return the first font in the
        // fontFamily This is to support built-in font Zapfino and other custom
        // single font families like Impact
        font = [UIFont fontWithName:fontNames[0] size:effectiveFontSize];
      }
    }
  }

Android 上怎么创建字体

Android 上的字体类是 Typeface https://developer.android.google.cn/reference/android/graphics/Typeface

和 iOS 的 UIFont 不同的是,创建 Typeface 需要的是字体族名称而不是字体名称,Typefacecreate 有三个重载方法:

  • create(Typeface family, int weight, boolean italic)
  • create(Typeface family, int style)
  • create(String familyName, int style)

React Native 的 ReactTypefaceUtilsReactFontManager 就是使用的 create 方法创建字体,具体代码不再摘录。

到这里我们发现 React Native iOS 平台同时支持设置字体族名称和字体名称,而 Android 平台只支持设置字体族名称。得出这样的结论和 https://github.com/keqingrong/font-example 中的实验相符。

// https://github.com/keqingrong/font-example/blob/master/FontFamilyExample.js
const fontStack = [
  'PingFang SC', // OK on iOS
  'PingFangSC-Semibold', // OK on iOS

  'Songti SC',
  'STSongti-SC-Regular',

  'Hiragino Sans', // OK on iOS
  'HiraginoSans-W3', // OK on iOS
  'HiraginoSans-W7', // OK on iOS

  'Hiragino Maru Gothic ProN', // OK on iOS
  'Hiragino Mincho ProN', // OK on iOS

  'sans-serif', // OK on Android
  'serif', // OK on Android
  'monospace', // OK on Android
  'source-sans-pro', // OK on Android

  'Droid Sans Fallback',

  'Noto Sans CJK',
  'Noto Sans CJK SC',
  'NotoSansCJK-Regular',

  'Noto Serif CJK',
  'Noto Serif CJK SC',
  'NotoSerifCJK-Regular',
].map(fontFamily => ({fontFamily, key: fontFamily}));

与此同时又产生新的问题:

  1. 为什么 iOS 上设置苹方(PingFang SC)生效,而宋体-简(Songti SC)不生效?
  2. 为什么 Android 上设置思源黑体(Noto Sans CJK)不生效,设置类似 CSS 的通用字体族生效?

原生支持哪些字体

很久之前我建过一个仓库 https://github.com/keqingrong/system-fonts 希望提供操作系统字体列表数据,但只支持 Windows 和 macOS,一直没有添加 Android 和 iOS 数据,后续会进行更新。

iOS 支持哪些字体

iOS 平台支持哪些字体,目前已知的一个来源是 https://developer.apple.com/fonts/system-fonts/,可以获取到 iOS 13 的字体支持数据。

我们发现:

  • PingFang SC 预装
  • Songti SC 非预安装,可下载
  • Heiti SC Light 非预安装,可下载
  • STSong 非预安装,可下载
  • STXihei 非预安装,可下载
  • STKaiti 非预安装,可下载
  • STFangsong 非预安装,可下载
  • Hiragino Sans W3 预装
  • Hiragino Sans W7 预装
  • Hiragino Maru Gothic ProN 预装
  • Hiragino Mincho ProN 预装

所以宋体-简不生效的原因是在 iOS 上压根没有安装……并且中文只能用苹方,曾经的华文黑体不再内置在系统中,比较奇怪的是日文却是有明朝体可以用。

Android 支持哪些字体

Android 平台支持的字体可以查看 Android Open Source Project (AOSP) 项目,比如 Android 11 https://github.com/aosp-mirror/platform_frameworks_base/blob/android11-release/data/fonts/fonts.xml

部分 fonts.xml 文件内容如下:

<?xml version="1.0" encoding="utf-8"?>
<familyset version="23">
    <!-- first font is default -->
    <family name="sans-serif">
        <font weight="100" style="normal">Roboto-Thin.ttf</font>
        <font weight="100" style="italic">Roboto-ThinItalic.ttf</font>
        <font weight="300" style="normal">Roboto-Light.ttf</font>
        <font weight="300" style="italic">Roboto-LightItalic.ttf</font>
        <font weight="400" style="normal">Roboto-Regular.ttf</font>
        <font weight="400" style="italic">Roboto-Italic.ttf</font>
        <font weight="500" style="normal">Roboto-Medium.ttf</font>
        <font weight="500" style="italic">Roboto-MediumItalic.ttf</font>
        <font weight="900" style="normal">Roboto-Black.ttf</font>
        <font weight="900" style="italic">Roboto-BlackItalic.ttf</font>
        <font weight="700" style="normal">Roboto-Bold.ttf</font>
        <font weight="700" style="italic">Roboto-BoldItalic.ttf</font>
    </family>

    <!-- Note that aliases must come after the fonts they reference. -->
    <alias name="sans-serif-thin" to="sans-serif" weight="100" />
    <alias name="sans-serif-light" to="sans-serif" weight="300" />
    <alias name="sans-serif-medium" to="sans-serif" weight="500" />
    <alias name="sans-serif-black" to="sans-serif" weight="900" />
    <alias name="arial" to="sans-serif" />
    <alias name="helvetica" to="sans-serif" />
    <alias name="tahoma" to="sans-serif" />
    <alias name="verdana" to="sans-serif" />

    <family name="serif">...</family>
    <alias name="serif-bold" to="serif" weight="700" />
    <alias name="times" to="serif" />
    <alias name="times new roman" to="serif" />
    <alias name="palatino" to="serif" />
    <alias name="georgia" to="serif" />
    <alias name="baskerville" to="serif" />
    <alias name="goudy" to="serif" />
    <alias name="fantasy" to="serif" />
    <alias name="ITC Stone Serif" to="serif" />

    <family name="monospace">...</family>
    <alias name="sans-serif-monospace" to="monospace" />
    <alias name="monaco" to="monospace" />

    <!-- fallback fonts -->
    <family lang="zh-Hans">
        <font weight="400" style="normal" index="2">NotoSansCJK-Regular.ttc</font>
        <font weight="400" style="normal" index="2" fallbackFor="serif">NotoSerifCJK-Regular.ttc</font>
    </family>
    <family lang="zh-Hant,zh-Bopo">
        <font weight="400" style="normal" index="3">NotoSansCJK-Regular.ttc</font>
        <font weight="400" style="normal" index="3" fallbackFor="serif">NotoSerifCJK-Regular.ttc</font>
    </family>
</familyset>

其中只定义了 sans-serifserifmonospace 等通用字体族,并将 arialhelveticatimes 等常见字体通过别名映射到通用字体族,中文简体、繁体、注音符号回退到 NotoSansCJK-Regular.ttc

NotoSansCJK-Regular.ttc 可以去 Noto CJK https://www.google.com/get/noto/help/cjk/ 页面下载,其中包含多个语言的字体,即 Noto Sans CJK {JP, KR, SC, TC},中文部分字体族为 Noto Sans CJK SC

所以尽管 AOSP 自带了 Droid Sans Fallback 和 Regular 字重的 Noto Sans CJK SC,但因为只作为回退字体,没有设置 <family name="Noto Sans CJK SC">...</family>,从而无法在 React Native 中使用。

国内定制版 Android 支持哪些字体

要回答这个问题需要花时间精力获取到相应的 ROM 固件,这里只找到一个 MIUI 12 字体列表的 Gist https://gist.github.com/aweffr/d5efe15826795649f99ec1e4ed8932c4

    <!-- MIUI fonts begin /-->
    <family name="miui">
        <font weight="400" style="normal">MiLanProVF.ttf
            <axis tag="wght" stylevalue="340"/>
        </font>
        <font weight="700" style="normal">MiLanProVF.ttf
            <axis tag="wght" stylevalue="400"/>
        </font>
    </family>

    <family name="mipro">
        <font weight="400" style="normal">MiLanProVF.ttf
            <axis tag="wght" stylevalue="340"/>
        </font>
        <font weight="700" style="normal">MiLanProVF.ttf
            <axis tag="wght" stylevalue="400"/>
        </font>
    </family>

    <family lang="zh-Hans">
        <font weight="400" style="normal">Miui-Regular.ttf</font>
    </family>
    <family lang="zh-Hant">
        <font weight="400" style="normal">Miui-Regular.ttf</font>
    </family>
    <family lang="zh-Hans">
        <font weight="400" style="normal" index="2">NotoSansCJK-Regular.ttc</font>
        <font weight="400" style="normal" index="2" fallbackFor="serif">NotoSerifCJK-Regular.ttc</font>
    </family>
    <family lang="zh-Hant,zh-Bopo">
        <font weight="400" style="normal" index="3">NotoSansCJK-Regular.ttc</font>
        <font weight="400" style="normal" index="3" fallbackFor="serif">NotoSerifCJK-Regular.ttc</font>
    </family>

MIUI 12 在 AOSP 的基础上增加了 miuimipro,都指向可变字体 MiLanProVF.ttf,即 小米兰亭Pro VF,中文回退字体增加了 Miui-Regular.ttf,即 Regular 字重的 小米兰亭

// https://github.com/keqingrong/font-example/blob/master/FontFamilyMIUIExample.js
const fontStack = [
  'sans-serif', // 中文部分回退为小米兰亭
  'serif', // 中文部分回退为思源宋体
  'monospace', // 中文部分回退为思源黑体
  'source-sans-pro', // 中文部分回退为思源黑体

  'miui', // 小米兰亭Pro VF
  'mipro', // 小米兰亭Pro VF
].map(fontFamily => ({fontFamily, key: fontFamily}));

如何区分小米兰亭和思源黑体呢?以 字为例,小米兰亭版 相连,思源黑体版不相连。

字重支持情况如何

iOS 字重

fontFamily 设置为 PingFang SCfontWeight 支持 100 ~ 900,boldnormal。由于苹方只有 7 个字重,所以 700、800、900 中文部分粗细一样,西文部分回退成默认英文字体,支持 9 款字重。

不设置 fontFamily,效果同上。

Android 字重

Android 平台比较令人沮丧。

fontFamily 设置为 sans-seriffontWeight 除了设置为 bold 会加粗,其余效果同 400 字重。西文部分也只显示一款字重。

fontFamily 设置为 serif,实验结果和 sans-serif 一致,仅 bold 会加粗。

不设置 fontFamily,西文部分支持 100 ~ 900,boldnormal。中文部分,600 ~ 900 和 bold 显示为 700 字重,其余显示为 400 字重。

React Native 中文文字截断问题

在查找资料的过程了发现了以下中文文字截断问题:

比较遗憾的是基本都是报问题和发截图,却没有提供可复现的代码。有回复猜测因为 MIUI 12 默认字体小米兰亭黑 Pro VF 是可变字体,react-native可能不支持。但我在 MIUI 12 上测试了 0.55.40.65.1 两个版本的 <Text> 展示,都没有出现文字截断。在 MIUI 12 上,不设置 fontFamily,或者像这篇 如何修改 React Native 的默认字体 设置成空字符串,渲染时的中文字体实际会回退成小米兰亭。

简单总结

本文在上一篇博客的基础上补充了 React Native 的 <Text> 组件在 Android 上如何实现,同时探究原生代码如何处理字体。关于 Android 如何设置有效字重,国产 Android 如何设置字体,还需要更多实践分析。

相关链接