-
Notifications
You must be signed in to change notification settings - Fork 24.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
custom fonts support The Android Way #24595
Conversation
ReactAndroid/src/main/java/com/facebook/react/views/text/ReactFontManager.java
Outdated
Show resolved
Hide resolved
This is only one for an instance, and it's singleton. IMHO, asking
developers to write a custom provider code just to have a custom font is a
bad DX.
|
It's still gonna be in the RN startup path, so it would still affect startup perf.
You can always provide a default provider, and have the ability for apps to change or disable the default provider. |
@dulmandakh to clarify, calling this method in RN's startup path means that it will literally add 10s of ms to the startup. FB scale is crazy. |
ok, i'll figure out a solution for it, thank you. |
@fkgozali changed it to look into meta-data in AndroidManifest.xml, thus not lookup resources by name. It requires some effort from developers, but it's much easier and clean way to add custom fonts on Android. |
for (int i = 0; i < fonts.length(); i++) { | ||
int fontId = fonts.getResourceId(i, 0); | ||
if (fontId != 0) { | ||
Typeface font = ResourcesCompat.getFont(context, fontId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How expensive is loading Typeface
instances for every single font of the app into the cache and calling resources.getResourceEntryName
at startup?
If it is also taking too long (for FB scale), although it's verbose you might do something like:
<array name="fonts">
<item>srisakdi</item>
<item>@font/srisakdi</item>
<item>anotherfont</item>
<item>@font/anotherfont</item>
</array>
This way you can iterate the array in increments of 2, and get away with only caching fontFamily
->fontId
mapping at startup, and only inflate fontId
->Typeface
still lazily.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also this should allow font file names to not strictly match font family names, ex:
<item>Open Sans</item>
<item>@font/open_sans</item>
Native devs will be more familiar underscored naming R.font.open_sans
whereas web developers will be more familiar with human readable naming fontFamily: "Open Sans"
.
This will allow sharing cross platform styling code with iOS, where font family name is already retrieved from font file content, not from file names.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried to mimic iOS feature, where we only list font files, but also make typo-proof. Also I couldn't find anything about performance impact of getResourceEntryName, and hope it won't have impact on performance.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
iOS works a bit differently, it reads font family name from the file content, Android in your implementation reads from the file name.
https://developer.apple.com/documentation/uikit/text_display_and_fonts/adding_a_custom_font_to_your_app
https://stackoverflow.com/questions/16788330/how-do-i-get-the-font-name-from-an-otf-or-ttf-file
IMO this will force developers to write strange JS code like:
{fontFamily: Platform.OS == 'ios' ? 'Open Sans' : 'open_sans'}
I dont know why typo-proof is such an important thing, you only need to match with what you use in JS code, you could make a mistake in font file name too.
Just looking at getResourceEntryName implementation in AOSP repo, it's implemented in C++ so you will at least jump between JNI bridge for each call.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, I found that Android doesn't allow to use font family name, but resource filename without extension. So difference is inevitable.
With this change we can use various font weights like medium easily. Currently, you have to write something like which makes text style sharing difficult.
{
fontFamily: Platform.OS == 'ios' ? 'Open Sans' : 'open_sans_medium'
fontWeight: Platform.OS == 'ios' ? '500' : 'normal'
}
once PR lands you will write
{
fontFamily: Platform.OS == 'ios' ? 'Open Sans' : 'open_sans'
fontWeight: '500'
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can currently name your file "Open Sans.ttf", "Open Sans_bold.ttf"... etc though, so the code can already be cross platform fontFamily: "Open Sans"
. Can you leave a space in these font resource file names and reuse that resource in Java? IMO it's not good practice to enforce android resource naming conventions into JS land.
try { | ||
ApplicationInfo app = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); | ||
Bundle bundle = app.metaData; | ||
int fontsId = bundle.getInt("rn_fonts", 0); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this be namespaced like com.facebook.react.font.resources
instead of rn_fonts
?
Bundle bundle = app.metaData; | ||
int fontsId = bundle.getInt("rn_fonts", 0); | ||
if (fontsId != 0) { | ||
Resources resources = context.getResources(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, I'm not sure if accessing anything dynamically from Resources is fast, because it needs to crack them open from the APK.
Can we make this not do any resource lookup by default and only perform this lookup if the hosting app configures it? You don't really need to change it to use AndroidManifest, using font resource is fine, but I'd like:
- no lookup by default
- do custom lookup with any impl for now only if the hosting app enables it explicitly
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think @dulmandakh is trying to find a way where non-native developers don't have to touch java code to customize these things, that's why he switched to using AndroidManifest that's supposed to be a cheaper alternative to resource lookups.
If an app like FB does not provide "rn_fonts" in AndroidManifest.xml, there will never be any look up (besides looking to AndroidManifest itself), isn't that sufficient?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
got it. I'll figure out another solution :D
@fkgozali another implementation which addresses your concerns, but also requires less work from developers. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mostly nits
import androidx.annotation.Nullable; | ||
import androidx.core.content.res.ResourcesCompat; | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove extra newline
@@ -37,9 +40,12 @@ | |||
private static ReactFontManager sReactFontManagerInstance; | |||
|
|||
private Map<String, FontFamily> mFontCache; | |||
private Map<String, Typeface> mTypeCache; | |||
private boolean mTypeCacheLoaded = false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think a better set of names is:
mCustomFontCache
mHasCustomFont
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
or:
mCustomTypefaceCache
mHasCustomTypeface
@@ -71,6 +83,15 @@ public static ReactFontManager getInstance() { | |||
return typeface; | |||
} | |||
|
|||
public void loadFont(Context context, int fontId) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should rename to addCustomFont()
to make it clearer
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you also add comment for how to call this method? E.g. what you did for RNTester. Basically document how one can install a custom font support in their apps.
public void loadFont(Context context, int fontId) { | ||
Typeface font = ResourcesCompat.getFont(context, fontId); | ||
if (font != null) { | ||
String fontFamily = context.getResources().getResourceEntryName(fontId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This won't affect FB with this PR, but I wonder if this is efficient in practice. You don't have to address it in this PR, but I think allowing apps to provide a custom "typeface resolution" function can allow them to address slow lookup. For instance, if one day FB wants to use custom fonts, I'd actually implement the resolver via a switch statement, not via asking getResourceEntryName()
. Most apps probably don't need such optimization, but having an option to do optimized thing is good.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Most apps probably don't need such optimization, but having an option to do optimized thing is good.
An example pseudo solution:
public interface ReactTypefaceResolver {
public String resolveTypefaceName(int fontId);
}
Then introduce a method in ReactFontManager:
public void setCustomTypefaceResolver(ReactTypefaceResolver resolver) {
mCustomTypefaceResolver = resolver;
}
Then you can just use mCustomTypefaceResolver != null
as the flag, no need to have the boolean flag you had above. Then you can just call it inside loadFont()
.
Thank you for the detailed review. I'll make the changes you suggested.
|
@fkgozali renamed variables and removed resource lookup code, therefore resolved feature parity with iOS because we can assign any font family name to a font. Thank you |
style | ||
); | ||
} | ||
|
||
Typeface typeface = fontFamily.getTypeface(style); | ||
if (typeface == null) { | ||
typeface = createTypeface(fontFamilyName, style, assetManager); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there value caching invidual styled Typeface (normal/italic/bold/boldItalic) instances here like it's done for fonts in assets? Besides that all LGTM.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
to use styles or weights you have to define fonts using XML.
@@ -60,6 +65,13 @@ public static ReactFontManager getInstance() { | |||
mFontCache.put(fontFamilyName, fontFamily); | |||
} | |||
|
|||
if(mHasCustomTypeface && mCustomTypefaceCache.containsKey(fontFamilyName)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: add space after if
@@ -37,9 +40,12 @@ | |||
private static ReactFontManager sReactFontManagerInstance; | |||
|
|||
private Map<String, FontFamily> mFontCache; | |||
private Map<String, Typeface> mCustomTypefaceCache; | |||
private boolean mHasCustomTypeface = false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Technically you could just make mCustomTypefaceCache
@Nullable
, then default to null
. If someone adds a custom typeface, you initialize it as a new HashMap, then you don't need mHasCustomTypeface
boolean. You can just check if mCustomTypefaceCache != null
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, my first version was like that but changed it to use booleans just before the commit because this seemed cleaner.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
from @mdvacca:
let's add final to these variables
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@fkgozali has imported this pull request. If you are a Facebook employee, you can view this diff on Phabricator.
@hramos it's not merged yet. Could you please investigate it. Thank you |
No one kicked off the merging. I still need to run the simulated perf test to verify there's no regression. |
@@ -37,9 +40,12 @@ | |||
private static ReactFontManager sReactFontManagerInstance; | |||
|
|||
private Map<String, FontFamily> mFontCache; | |||
private Map<String, Typeface> mCustomTypefaceCache; | |||
private boolean mHasCustomTypeface = false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
from @mdvacca:
let's add final to these variables
* | ||
* ReactFontManager.getInstance().addCustomFont(this, "Srisakdi", R.font.srisakdi); | ||
*/ | ||
public void addCustomFont(Context context, @NonNull String fontFamily, int fontId) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
from @mdvacca:
should we add @NonNull
to context?
if (font != null) { | ||
mCustomTypefaceCache.put(fontFamily, font); | ||
} | ||
mHasCustomTypeface = true; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From @mdvacca:
doesn't this need to be inside the if (font != null) {
also, why not to just remove this variable and create a private method:
private boolean hasCustomTypeface() {
return mCustomTypefaceCache.isEmpty();
}
or maybe we don't even need this variable, it is just preventing a call to Map.containsKey?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we pass resource id to this method, but that ID might not be a font, but anything. So font != null is making sure that we have a font with provided id.
seems like we can land this for now, just need to address the comments here before we land |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@fkgozali has imported this pull request. If you are a Facebook employee, you can view this diff on Phabricator.
This pull request was successfully merged by @dulmandakh in fd6386a. When will my fix make it into a release? | Upcoming Releases |
Summary
In #23865, RN introduced support for custom fonts the Android Way. But it introduced performance regression because it'll lookup for a font using getIdentifier() every time fontFamily changed. This PR fixes regression by requiring custom fonts to be listed in fonts array, and populating mTypeCache at first use using the list.
Changelog
[Android] [Changed] - Require custom fonts to list in fonts array. Fixes performance regression.
Test Plan
CI is green and RNTester app works as expected.