本文继续讨论 <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
继承自 ReactBaseTextShadowNode
,spannedFromShadowNode()
定义在 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 asTimes New Roman
that identifies one or more specific fonts.fontName
: The font face name. The font name is a name such asHelveticaBold
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
相应的字体名称列表,能查到数据,则进一步根据 style
、weight
匹配字体,不能查到,则把 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
需要的是字体族名称而不是字体名称,Typeface
的 create
有三个重载方法:
create(Typeface family, int weight, boolean italic)
create(Typeface family, int style)
create(String familyName, int style)
React Native 的 ReactTypefaceUtils
和 ReactFontManager
就是使用的 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}));
与此同时又产生新的问题:
- 为什么 iOS 上设置苹方(
PingFang SC
)生效,而宋体-简(Songti SC
)不生效? - 为什么 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-serif
、serif
、monospace
等通用字体族,并将 arial
、helvetica
、times
等常见字体通过别名映射到通用字体族,中文简体、繁体、注音符号回退到 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 的基础上增加了 miui
和 mipro
,都指向可变字体 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 SC
,fontWeight
支持 100 ~ 900,bold
和 normal
。由于苹方只有 7 个字重,所以 700、800、900 中文部分粗细一样,西文部分回退成默认英文字体,支持 9 款字重。
不设置 fontFamily
,效果同上。
Android 字重
Android 平台比较令人沮丧。
fontFamily
设置为 sans-serif
,fontWeight
除了设置为 bold
会加粗,其余效果同 400 字重。西文部分也只显示一款字重。
fontFamily
设置为 serif
,实验结果和 sans-serif
一致,仅 bold
会加粗。
不设置 fontFamily
,西文部分支持 100 ~ 900,bold
和 normal
。中文部分,600 ~ 900 和 bold
显示为 700
字重,其余显示为 400 字重。
React Native 中文文字截断问题
在查找资料的过程了发现了以下中文文字截断问题:
比较遗憾的是基本都是报问题和发截图,却没有提供可复现的代码。有回复猜测因为 MIUI 12 默认字体小米兰亭黑 Pro VF 是可变字体,react-native
可能不支持。但我在 MIUI 12 上测试了 0.55.4
和 0.65.1
两个版本的 <Text>
展示,都没有出现文字截断。在 MIUI 12 上,不设置 fontFamily
,或者像这篇 如何修改 React Native 的默认字体 设置成空字符串,渲染时的中文字体实际会回退成小米兰亭。
简单总结
本文在上一篇博客的基础上补充了 React Native 的 <Text>
组件在 Android 上如何实现,同时探究原生代码如何处理字体。关于 Android 如何设置有效字重,国产 Android 如何设置字体,还需要更多实践分析。
相关链接
- Android Native Modules https://reactnative.dev/docs/native-modules-android
- Android Native UI Components https://reactnative.dev/docs/native-components-android
- Spans https://developer.android.google.cn/guide/topics/text/spans
- Typeface https://developer.android.google.cn/reference/android/graphics/Typeface
- UIFont
- NSFont
- Android Open Source Project https://github.com/aosp-mirror/