React Native 字体探究

引子

前几天刷知乎,刷到字节前端的一篇文章 深入了解魔性的CSS字体,一阵感概,大部分前端工程师的字体知识都是一片空白,现在终于逐渐有人注意到这些问题。我在文章下面进行了评论,尝试回答文章开头提出的问题:

第一个问题。PingFangSC-Regular 为什么不支持 500 字重呢?因为它就是 Regular 字重(对应 400),自然不存在把 400 字重的字体指定成 500 这种事。PingFangSC-Regular 是字体全称,在 CSS 中应该使用字体族(font family)而不是字体全称(full font name),所以 CSS 的那个属性叫 font-family

第二个问题。为什么有的 Android 手机不支持粗体呢?因为中文字体文件体积太大,所以 Android 的 AOSP 只附带了 Regular 字重的 Noto Sans CJK SC Regular 和祖传的回退字体 Droid Sans Fallback(也只有一款字重)。那为什么有的又支持粗体呢?其一,软件层面实现的伪粗体,其二,小米、华为等厂商在系统中附带了支持多款字重的字体,比如小米兰亭、鸿蒙黑体,甚至用上最新的可变字体,比如小米的小米兰亭Pro VF,允许开发者放飞自我,定义 [1, 1000] 之间的任意字重。

总的来说因为这篇文章让我决定补一补几年前写博客时留下的坑。

这里先补 React Native 的坑。

从 React Native 字体示例开始

昨天在 GitHub 上建了一个仓库 https://github.com/keqingrong/font-example 用于验证字体设置在 iOS 和 Android 上是否生效。黑盒验证总是不如白盒验证来得彻底,所以本文直接看 React Native 的源码一探究竟。

React Native 中一般这样设置字体属性:

import { Text } from 'react-native'

// ...
<Text style={{ fontFamily: 'PingFang SC', fontWeight: 'bold' }} >
// ...

所以先看 Text 组件从哪儿来的。

<Text> 的 JS 实现

Text 模块由 react-native 包导出,react-native@0.65.1package.json 没有定义 main 字段,入口直接是根目录下的 index.js

// react-native/index.js
import typeof Text from './Libraries/Text/Text';

module.exports = {
  // ...
  get Text(): Text {
    return require('./Libraries/Text/Text');
  },
}

有两段涉及 Text 的,第一段是 Flow 类型定义,第二段是引入和导出具体模块(定义成 getter 形式是为了在组件和 API 变更时加迁移提示)。

Libraries 下是传说中的原生模块(Native Modules)和原生组件(Native Components),这里我们只关心 Text 文件夹。

# react-native/Libraries/Text/
.
├── BaseText/
├── RawText/
├── Text/
├── TextInput/
├── VirtualText/
├── RCTConvert+Text.h
├── RCTConvert+Text.m
├── RCTTextAttributes.h
├── RCTTextAttributes.m
├── RCTTextTransform.h
├── React-RCTText.podspec
├── Text.js
├── TextAncestor.js
├── TextInjection.js
├── TextNativeComponent.js
└── TextProps.js

先看 Text.js,删减后的代码如下:

// react-native/Libraries/Text/Text.js
import TextAncestor from './TextAncestor';
import {NativeText, NativeVirtualText} from './TextNativeComponent';

const Text = () => {
  const hasTextAncestor = useContext(TextAncestor);

  return hasTextAncestor ? (
    <NativeVirtualText />
  ) : (
    <TextAncestor.Provider value={true}>
      <NativeText />
    </TextAncestor.Provider>
  );
}

TextAncestor 直译为“文本祖先”,因为 <Text> 组件可以嵌套,类似:

<View>
  <Text testID="parent">
    <Text testID="child">Hello</Text>
  </Text>
</View>

child 有文本祖先,parent 没有,有的话需要继承祖先元素的文本样式,没有的话自己成为祖先。

再看 TextNativeComponent.js,删减后的代码如下:

// react-native/Libraries/Text/TextNativeComponent.js
import createReactNativeComponentClass from '../Renderer/shims/createReactNativeComponentClass';

const NativeText = createReactNativeComponentClass('RCTText', () => ({}));

const NativeVirtualText = createReactNativeComponentClass('RCTVirtualText', () => ({}));

通过调用 createReactNativeComponentClass() 创建原生组件类。

react-native 对外的创建原生组件的 API requireNativeComponent() 内部也是调用的该方法:

// react-native/Libraries/ReactNative/requireNativeComponent.js
const requireNativeComponent = (uiViewClassName) => createReactNativeComponentClass(
  uiViewClassName,
  () => getNativeComponentAttributes(uiViewClassName)
);

module.exports = requireNativeComponent;

createReactNativeComponentClass() 内部又调用了 ReactNativeViewConfigRegistry.register()ReactNativeViewConfigRegistry 的代码如下,所谓的注册就是将组件名和获取组件视图配置的回调函数添加到 Map 中。

// react-native/Libraries/Renderer/shims/ReactNativeViewConfigRegistry.js
const viewConfigCallbacks = new Map();
const viewConfigs = new Map();

exports.register = function(name, callback) {
  viewConfigCallbacks.set(name, callback);
  return name;
};

exports.get = function(name) {
  let viewConfig;
  if (!viewConfigs.has(name)) {
    const callback = viewConfigCallbacks.get(name);
    viewConfig = callback();
    processEventTypes(viewConfig);
    viewConfigs.set(name, viewConfig);
    viewConfigCallbacks.set(name, null);
  } else {
    viewConfig = viewConfigs.get(name);
  }
  return viewConfig;
}

React Native 的渲染器(Renderer)在渲染时会为 React 组件创建实例,根据组件类型获取对应视图配置。

// react-native/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js
var ReactNativePrivateInterface = require("react-native/Libraries/ReactPrivate/ReactNativePrivateInterface");
var getViewConfigForType = ReactNativePrivateInterface.ReactNativeViewConfigRegistry.get;

function createInstance(
  type,
  props,
  rootContainerInstance,
  hostContext,
  internalInstanceHandle
) {
  var tag = allocateTag();
  var viewConfig = getViewConfigForType(type);

  {
    for (var key in viewConfig.validAttributes) {
      if (props.hasOwnProperty(key)) {
        ReactNativePrivateInterface.deepFreezeAndThrowOnMutationInDev(
          props[key]
        );
      }
    }
  }

  var updatePayload = create(props, viewConfig.validAttributes);
  ReactNativePrivateInterface.UIManager.createView(
    tag, // reactTag
    viewConfig.uiViewClassName, // viewName
    rootContainerInstance, // rootTag
    updatePayload // props
  );
  var component = new ReactNativeFiberHostComponent(
    tag,
    viewConfig,
    internalInstanceHandle
  );
  precacheFiberNode(internalInstanceHandle, tag);
  updateFiberProps(tag, props); // Not sure how to avoid this cast. Flow is okay if the component is defined
  // in the same file but if it's external it can't see the types.

  return component;
}

React Native 的渲染器实现都在 Libraries/Renderer 文件夹下,主要包括 ReactNativeRendererReactFabric

JS 部分暂时只看到这里,毕竟主线任务是看原生如何处理文字的字体,下面继续看 react-native/Libraries/Text/ 目录下的原生代码:

<Text> 的 Native 实现

# react-native/Libraries/Text/
.
├── BaseText/
├── RawText/
├── Text/
├── TextInput/
├── VirtualText/
├── RCTConvert+Text.h
├── RCTConvert+Text.m
├── RCTTextAttributes.h
├── RCTTextAttributes.m
├── RCTTextTransform.h
├── React-RCTText.podspec

RCTTextAttributes.hRCTTextAttributes.m 定义了一个 RCTTextAttributes 类,它的属性和 React Native 文档 Text Style Props 中定义的 props 对应(当然也同样是 <Text> 组件支持的 props)。

Objective-C 的语法中,@interface 虽然叫 interface,但实际用于定义类。@protocol 才相当于我们正常理解的接口。Objective-C 中的类除了在头文件中定义 @interface,还需要在源文件中通过 @implementation 进行详细实现。 可以参考官方文档 Programming with Objective-C

RCTTextAttributes.m 本身没有包含复杂的字体设置逻辑,其中使用了位于 react-native/React/Views/ 目录的 RCTFont 类(RCTFont.hRCTFont.mm)过于细节暂时忽略。

除了 TextInput 文件夹,其他主要包括如下 View 和相应的 ViewManager(用于创建、修改、销毁 View):

  • RCTTextView / RCTTextViewManager
    • RCTTextShadowView
      • RCTBaseTextShadowView / RCTBaseTextViewManager
        • RCTRawTextShadowView / RCTRawTextViewManager
        • RCTVirtualTextShadowView / RCTVirtualTextViewManager

其中 RCTBaseTextShadowView 引用了 RCTTextAttributes

RCTTextViewManager 管理的就是 <Text> 组件对应的视图 RCTTextView(对应 TextNativeComponent.js 中的 RCTText),可以看到其代码中使用了 RCT_EXPORT_MODULE()RCT_EXPORT_VIEW_PROPERTY() 等宏来对外导出模块、属性:

// react-native/Libraries/Text/Text/RCTTextViewManager.m
RCT_EXPORT_MODULE(RCTText)

RCT_REMAP_SHADOW_PROPERTY(numberOfLines, maximumNumberOfLines, NSInteger)
RCT_REMAP_SHADOW_PROPERTY(ellipsizeMode, lineBreakMode, NSLineBreakMode)
RCT_REMAP_SHADOW_PROPERTY(adjustsFontSizeToFit, adjustsFontSizeToFit, BOOL)
RCT_REMAP_SHADOW_PROPERTY(minimumFontScale, minimumFontScale, CGFloat)

RCT_EXPORT_SHADOW_PROPERTY(onTextLayout, RCTDirectEventBlock)

RCT_EXPORT_VIEW_PROPERTY(selectable, BOOL)

React Native 的 <Text> 没有直接使用 iOS 中的 UILabel,而是使用 Apple 提供的 TextKit 来自定义文本内容布局。具体来说包括以下三个类:

  • NSTextStorage
  • NSLayoutManager
  • NSTextContainer

RCTTextViewRCTTextShadowView 中都有体现。React Native 的 TextLayoutManager 在不同平台有着不同的实现,代码在 react-native/ReactCommon/react/renderer/textlayoutmanager/platform 目录下:

# react-native/ReactCommon/react/renderer/textlayoutmanager/platform
.
├── android
│   └── react
│       └── renderer
│           └── textlayoutmanager
│               ├── TextLayoutManager.cpp
│               └── TextLayoutManager.h
├── cxx
│   ├── TextLayoutManager.cpp
│   └── TextLayoutManager.h
└── ios
    ├── NSTextStorage+FontScaling.h
    ├── NSTextStorage+FontScaling.m
    ├── RCTAttributedTextUtils.h
    ├── RCTAttributedTextUtils.mm
    ├── RCTFontProperties.h
    ├── RCTFontUtils.h
    ├── RCTFontUtils.mm
    ├── RCTTextLayoutManager.h
    ├── RCTTextLayoutManager.mm
    ├── RCTTextPrimitivesConversions.h
    ├── TextLayoutManager.h
    └── TextLayoutManager.mm

我们主要看 RCTTextLayoutManager.hRCTTextLayoutManager.mm,通过 @interface 定义的 6 个方法中有 5 个都调用了同一个方法 _nsAttributedStringFromAttributedString()

  • measureAttributedString()
  • drawAttributedString()
  • getLinesForAttributedString()
  • getEventEmitterWithAttributeString()
  • getRectWithAttributedString()

如官方文档的 Nested text 所说,<Text> 在 iOS 上的实现基于 NSAttributedString_nsAttributedStringFromAttributedString() 的方法名正好验证这个说法。

// react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/RCTTextLayoutManager.mm
- (NSAttributedString *)_nsAttributedStringFromAttributedString:(AttributedString)attributedString
{
  auto sharedNSAttributedString = _cache.get(attributedString, [](AttributedString attributedString) {
    return wrapManagedObject(RCTNSAttributedStringFromAttributedString(attributedString));
  });

  return unwrapManagedObject(sharedNSAttributedString);
}

_nsAttributedStringFromAttributedString() 内部调用了 RCTAttributedTextUtils.mm 中的 RCTNSAttributedStringFromAttributedString(),其代码大致如下:

NSAttributedString *RCTNSAttributedStringFromAttributedString(const AttributedString &attributedString)
{
  // ...

  NSMutableAttributedString *nsAttributedString = [[NSMutableAttributedString alloc] init];

  [nsAttributedString beginEditing];

  for (auto fragment : attributedString.getFragments()) {
    NSMutableAttributedString *nsAttributedStringFragment;

    if (fragment.isAttachment()) {
      // ...

      nsAttributedStringFragment = [[NSMutableAttributedString attributedStringWithAttachment:attachment] mutableCopy];
    } else {
      NSString *string = [NSString stringWithCString:fragment.string.c_str() encoding:NSUTF8StringEncoding];

      nsAttributedStringFragment = [[NSMutableAttributedString alloc]
          initWithString:string
              attributes:RCTNSTextAttributesFromTextAttributes(fragment.textAttributes)];
    }

    // ...

    [nsAttributedString appendAttributedString:nsAttributedStringFragment];
  }

  [nsAttributedString endEditing];

  return nsAttributedString;
}

通过迭代 AttributedString 来生成 NSAttributedString,其中出现了上文提及的文本样式属性字段 textAttributesRCTNSTextAttributesFromTextAttributes(textAttributes) 的部分代码:

// react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/RCTAttributedTextUtils.mm
NSDictionary<NSAttributedStringKey, id> *RCTNSTextAttributesFromTextAttributes(TextAttributes const &textAttributes)
{
  NSMutableDictionary<NSAttributedStringKey, id> *attributes = [NSMutableDictionary dictionaryWithCapacity:10];

  // Font
  UIFont *font = RCTEffectiveFontFromTextAttributes(textAttributes);
  if (font) {
    attributes[NSFontAttributeName] = font;
  }

  // Colors
  UIColor *effectiveForegroundColor = RCTEffectiveForegroundColorFromTextAttributes(textAttributes);

  // ...
}

RCTEffectiveFontFromTextAttributes(textAttributes) 方法,部分代码如下:

// react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/RCTAttributedTextUtils.mm
inline static UIFont *RCTEffectiveFontFromTextAttributes(const TextAttributes &textAttributes)
{
  NSString *fontFamily = [NSString stringWithCString:textAttributes.fontFamily.c_str() encoding:NSUTF8StringEncoding];

  RCTFontProperties fontProperties;
  fontProperties.family = fontFamily;
  fontProperties.size = textAttributes.fontSize;
  fontProperties.weight = textAttributes.fontWeight.hasValue()
      ? RCTUIFontWeightFromInteger((NSInteger)textAttributes.fontWeight.value())
      : NAN;

  // ...

  return RCTFontWithFontProperties(fontProperties);
}

再看 RCTFontWithFontProperties() 方法,代码大致如下:

// react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/RCTFontUtils.mm
UIFont *RCTFontWithFontProperties(RCTFontProperties fontProperties)
{
  RCTFontProperties defaultFontProperties = RCTDefaultFontProperties();
  fontProperties = RCTResolveFontProperties(fontProperties, defaultFontProperties);

  // ...
}

调用静态方法 RCTDefaultFontProperties() 获取默认属性,然后又调用静态方法 RCTResolveFontProperties() 解析传入的 fontProperties 和默认属性。两个方法的代码如下:

// react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/RCTFontUtils.mm
static RCTFontProperties RCTDefaultFontProperties()
{
  static RCTFontProperties defaultFontProperties;
  static dispatch_once_t onceToken;

  dispatch_once(&onceToken, ^{
    defaultFontProperties.family = [UIFont systemFontOfSize:defaultFontProperties.size].familyName;
    defaultFontProperties.size = 14;
    defaultFontProperties.weight = UIFontWeightRegular;
    defaultFontProperties.style = RCTFontStyleNormal;
    defaultFontProperties.variant = RCTFontVariantDefault;
    defaultFontProperties.sizeMultiplier = 1.0;
  });

  return defaultFontProperties;
}

static RCTFontProperties RCTResolveFontProperties(
    RCTFontProperties fontProperties,
    RCTFontProperties baseFontProperties)
{
  fontProperties.family = fontProperties.family.length ? fontProperties.family : baseFontProperties.family;
  fontProperties.size = !isnan(fontProperties.size) ? fontProperties.size : baseFontProperties.size;
  fontProperties.weight = !isnan(fontProperties.weight) ? fontProperties.weight : baseFontProperties.weight;
  fontProperties.style =
      fontProperties.style != RCTFontStyleUndefined ? fontProperties.style : baseFontProperties.style;
  fontProperties.variant =
      fontProperties.variant != RCTFontVariantUndefined ? fontProperties.variant : baseFontProperties.variant;
  return fontProperties;
}

没有设置 fontFamily 的情况下展示系统默认字体,fontSize 默认为 14,fontWeight 默认为 UIFontWeightRegular

简单总结

React Native 上设置的 fontFamily 等属性最终会直接传递赋给原生组件,和 CSS 截然不同,这也导致了 fontFamily 只能限定某一款字体族名称,无法写成 "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif 形式。

相关链接