我的天,我真是和androidx的字体加载杠上了

某一天,我们设计小姐姐发我了一个新的字体包,让我替换一下App里面原有的字体包,我信誓旦旦的Ctrl+C然后Ctrl+V,然后我就得到如下的错误信息,😂

shell 复制代码
Caused by: java.lang.IllegalStateException: Unable to load font ResourceFont(resId=2131034115, weight=FontWeight(weight=400), style=Normal, loadingStrategy=Blocking)
                                 	at androidx.compose.ui.text.font.FontListFontFamilyTypefaceAdapterKt.firstImmediatelyAvailable(FontListFontFamilyTypefaceAdapter.kt:193)
...
Caused by: android.content.res.Resources$NotFoundException: Font resource ID #0x7f050003 could not be retrieved.
                                 	at androidx.core.content.res.ResourcesCompat.loadFont(ResourcesCompat.java:573)
                                 	at androidx.core.content.res.ResourcesCompat.getFont(ResourcesCompat.java:414)

当时看见这个报错信息的时候,我就想是不是Jetpack Compose字体加载的问题,看了眼我的代码感觉也没啥特别的,Compose里面很常见的字体加载方式了。

kotlin 复制代码
Text(
    text = "1234 $name!",
    modifier = Modifier
        .align(Alignment.TopCenter)
        .padding(top = 50.dp),
    fontFamily = FontFamily(Font(R.font.helvetica_roman_semi_b_3)),
)

然后就看了下FontFamily的源码,感觉应该也不是,就这么简单的代码。

kotlin 复制代码
@Stable
fun FontFamily(vararg fonts: Font): FontFamily = FontListFontFamily(fonts.asList())

接着又研究Font的源码,感觉这里面有些蹊跷,😏。

kotlin 复制代码
@Stable
fun Font(
    resId: Int,
    weight: FontWeight = FontWeight.Normal,
    style: FontStyle = FontStyle.Normal,
    loadingStrategy: FontLoadingStrategy = FontLoadingStrategy.Blocking
): Font = ResourceFont(resId, weight, style, FontVariation.Settings(), loadingStrategy)

Font函数除了接受字体的资源id外,还可以传字重weight属性,样式style属性和加载策略loadingStrategy属性。

因为设计小姐姐说这个字体包是一个粗体,我就思考是不是weight不能用FontWeight.Normal,要用字体包对应的字重。熟悉Compose的童鞋都知道,FontWeight类接受一个weight:Int的入参,并且它里面定义了从W100-W900的字重伴生对象。

kotlin 复制代码
@Immutable
class FontWeight(val weight: Int) : Comparable<FontWeight> 

我依次从100设置到900,该闪退的地方还是一样会闪退,该报错的信息还是一样会输出😂。

接着再研究FontStyle参数。FontStyle也简单,只有NormalItalic两个属性可以用,那应该也不是它们导致的异常了,但是秉着求真的思想,不能放过一条漏网之鱼,还是改了下参数试了试,也证明了确实不是它导致的。

最后就是FontLoadingStrategy参数了。FontLoadingStrategy是一个value class,在它的伴生对象也定义了3个值,分别为BlockingOptionalLocalAsync。大致含义就是Blocking会阻塞UI线程的去加载字体,OptionalLocal会优先加载本地字体,Async适合加载在线的字体。

不管怎样,每个参数都试一下就行了,但是结果还是一样的,到加载这个字体包文件时就异常闪退。

暂时能想到的办法都试过了,十分能确定就是这个字体包有问题了,就反馈给设计小姐姐能不能换个字体,然后得到也是经典的那句话:为什么啊?iOS都可以啊!,😂,作为苦逼的Android开发,这句话都快听起茧来了,没其他意思,就是很无奈。

不行!身为经验丰富(踩坑多)的工程师,😎,必须把这个问题解决了,接着继续想办法,什么直接用TextView直接加载这个字体,用Typeface类去加载这个字体,新建一个新的项目去加载这个字体,全都是一样,一样的闪退报错,😈。最离谱的是这个字体有很多字重版本,设计小姐姐发我的是bold版本,我自己从网上下载的regular版本都不报错,真是离谱,😈。不是哥们!就这样一天的时间都过去了,该当天完成的需求都还没做呢,就在这里填坑了,而且坑还没填上,白白的又比iOS哥们少了一天工期,难道又只有加班赶回来了,😭,当然最后的结果还是设计小姐姐换了个其他类型的字体。没办法,这就是Android!,别说产品和设计了,🐶都嫌弃。

后面这个版本上线了,终于有点空闲时间来继续研究这个问题了,刚开始我肯定是没怀疑是androidx的问题的,但是经过了上一次给androidx修bug的经历,我也才理解了,代码都是人写的,虽然Google工程师很厉害,但是在国内系统这么碎片化的情况下,一不小心就蕉泥座人,😏。所以我就继续研究源码了,从Font加载字体开始,还是使用打断点单步调试法,一层一层的往下走就行了,之后有一次看报错信息才发现,直接看ResourcesCompat.loadFont方法就行了,如本文开头的报错信息。

shell 复制代码
Caused by: android.content.res.Resources$NotFoundException: Font resource ID #0x7f050003 could not be retrieved.
                                 	at androidx.core.content.res.ResourcesCompat.loadFont(ResourcesCompat.java:573)
                                 	at androidx.core.content.res.ResourcesCompat.getFont(ResourcesCompat.java:414)

很明显的指出了异常发生在ResourcesCompat类中的getFont方法,然后里面再调用了loadFont方法。

java 复制代码
    private static Typeface loadFont(@NonNull Context context, int id, @NonNull TypedValue value,
            int style, @Nullable FontCallback fontCallback, @Nullable Handler handler,
            boolean isRequestFromLayoutInflator, boolean isCachedOnly) {
        final Resources resources = context.getResources();
        resources.getValue(id, value, true);
      	//
        Typeface typeface = loadFont(context, resources, value, id, style, fontCallback, handler,
                isRequestFromLayoutInflator, isCachedOnly);
      	//在这里抛出异常了
        if (typeface == null && fontCallback == null && !isCachedOnly) {
            throw new NotFoundException("Font resource ID #0x"
                    + Integer.toHexString(id) + " could not be retrieved.");
        }
        return typeface;
    }
java 复制代码
private static Typeface loadFont(
        @NonNull Context context, Resources wrapper, @NonNull TypedValue value, int id,
        int style, @Nullable FontCallback fontCallback, @Nullable Handler handler,
        boolean isRequestFromLayoutInflator, boolean isCachedOnly) {
			//删除一些无用代码
    try {
        typeface = TypefaceCompat.createFromResourcesFontFile(
                context, wrapper, id, file, value.assetCookie, style);
      //删除一些无用代码
        return typeface;
    } catch (XmlPullParserException e) {
        Log.e(TAG, "Failed to parse xml resource " + file, e);
    } catch (IOException e) {
        Log.e(TAG, "Failed to read xml resource " + file, e);
    }
    return null;
}

loadFont方法返回的Typeface就是Text组件要用的字体类型,但是这个typeface又是TypefaceCompat.createFromResourcesFontFile方法返回的,继续往里走。

java 复制代码
public static Typeface createFromResourcesFontFile(
        @NonNull Context context, @NonNull Resources resources, int id, String path, int cookie,
        int style) {
    Typeface typeface = sTypefaceCompatImpl.createFromResourcesFontFile(
            context, resources, id, path, style);
    if (typeface != null) {
        final String resourceUid = createResourceUid(resources, id, path, cookie, style);
        sTypefaceCache.put(resourceUid, typeface);
    }
    return typeface;
}

然后又从上面代码可得知,typeface来源于sTypefaceCompatImpl.createFromResourcesFontFile方法,之前那篇文章讲过,sTypefaceCompatImpl是分版本创建的。

java 复制代码
private static final TypefaceCompatBaseImpl sTypefaceCompatImpl;
static {
    if (Build.VERSION.SDK_INT >= 29) {
        sTypefaceCompatImpl = new TypefaceCompatApi29Impl();
    } else if (Build.VERSION.SDK_INT >= 28) {
        sTypefaceCompatImpl = new TypefaceCompatApi28Impl();
    } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        sTypefaceCompatImpl = new TypefaceCompatApi26Impl();
    } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
            && TypefaceCompatApi24Impl.isUsable()) {
        sTypefaceCompatImpl = new TypefaceCompatApi24Impl();
    } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        sTypefaceCompatImpl = new TypefaceCompatApi21Impl();
    } else {
        sTypefaceCompatImpl = new TypefaceCompatBaseImpl();
    }
}

我们先从TypefaceCompatApi29Impl看着走吧。直接跳转到TypefaceCompatApi29Impl中的createFromResourcesFontFile

java 复制代码
public Typeface createFromResourcesFontFile(
        Context context, Resources resources, int id, String path, int style) {
    FontFamily family = null;
    Font font = null;
    try {
        font = new Font.Builder(resources, id).build();
        family = new FontFamily.Builder(font).build();
        return new Typeface.CustomFallbackBuilder(family)
                // Set font's style to the display style for backward compatibility.
                .setStyle(font.getStyle())
                .build();
    } catch (Exception e) {//注意这里捕获了Exception,但是没输出结果。
        return null;
    }
}

没办法,还是单步一步一步的往下执行,执行family = new FontFamily.Builder(font).build();这行代码时程序就异常闪退了,我们打断点继续往build方面里面进。

java 复制代码
public @NonNull FontFamily build(@NonNull String langTags, int variant,
        boolean isCustomFallback, boolean isDefaultFallback) {
    final long builderPtr = nInitBuilder();
    for (int i = 0; i < mFonts.size(); ++i) {
        nAddFont(builderPtr, mFonts.get(i).getNativePtr());
    }
  	//这行代码的锅
    final long ptr = nBuild(builderPtr, langTags, variant, isCustomFallback,
            isDefaultFallback);
    final FontFamily family = new FontFamily(ptr);
    sFamilyRegistory.registerNativeAllocation(family, ptr);
    return family;
}

继续单步继续走,然后就是执行nBuild方法闪退了,继续看看nBuild方法呢。

java 复制代码
private static native long nBuild(long builderPtr, String langTags, int variant,
        boolean isCustomFallback, boolean isDefaultFallback);

WTF❓,这都是到native方法了,真🔨难搞,没办法继续前进继续冲,打破砂锅问到底,打开AOSP在线源码,直接搜索FontFmaily.cppnBuild两个关键字。我这里使用的是Android Code Search,挺好用的。然后就找到了FontFamily_Builder_build方法。

cpp 复制代码
static jlong FontFamily_Builder_build(JNIEnv* env, jobject clazz, jlong builderPtr,
                                      jstring langTags, jint variant, jboolean isCustomFallback,
                                      jboolean isDefaultFallback, jint variationFamilyType) {
    std::unique_ptr<NativeFamilyBuilder> builder(toBuilder(builderPtr));
    uint32_t localeId;
    if (langTags == nullptr) {
        localeId = minikin::registerLocaleList("");
    } else {
        ScopedUtfChars str(env, langTags);
        localeId = minikin::registerLocaleList(str.c_str());
    }
    std::shared_ptr<minikin::FontFamily> family = minikin::FontFamily::create(
            localeId, static_cast<minikin::FamilyVariant>(variant), std::move(builder->fonts),
            isCustomFallback, isDefaultFallback,
            static_cast<minikin::VariationFamilyType>(variationFamilyType));
  	//哦?这里jni抛了个异常给java层
    if (family->getCoverage().length() == 0) {
        // No coverage means minikin rejected given font for some reasons.
        jniThrowException(env, "java/lang/IllegalArgumentException",
                          "Failed to create internal object. maybe invalid font data");
        return 0;
    }
    return reinterpret_cast<jlong>(new FontFamilyWrapper(std::move(family)));
}

这个方法里面的代码看起来倒是不难,但是已经是native里面的方法了,也不能单步调试了,实在想调试只能自己编译AOSP了,这个工作量就很大了。但是从createFromResourcesFontFile方法里面分析得知,只有走到了catch分支才返回了null的typeface,最终在loadFont方法中判断了typeface == null才抛出了异常。恰好这个FontFamily_Builder_buildnative方法jniThrowException了一个IllegalArgumentException,所以我们在createFromResourcesFontFile方法的catch分支打个断点看看。

果然,就是family->getCoverage().length() == 0这里的锅,看来这个bug产生的原因大概就是:native方法里面创建字体的FontFamily属性不成功就直接抛出了异常,但是说到底也还是这个字体包有问题。并且只要有地方是调用了FontFamily.Builder(font).build()去加载这个字体包的话也是一样会抛异常的,比如我们常用的Typeface这个类。

当时调试到这里的时候,差不多就要放弃了,感觉这个bug实在是修不好了,但是之后有一天突然灵光一现,想到sTypefaceCompatImpl是分版本创建的呀,那么有没有一种可能是只有TypefaceCompatApi29Impl这个分支是有问题的,其他分支可能是没问题的,我就继续测试,结果还果真这样。结论就是>=TypefaceCompatApi26Impl的分支都是会闪退的,其他的分支是可以正常加载字体的,也就是TypefaceCompatApi24ImplTypefaceCompatApi21ImplTypefaceCompatBaseImpl都是没问题。继续分析源码得知其实这个不会造成闪退的类都是走到了TypefaceCompatBaseImplcreateFromResourcesFontFile方法。

java 复制代码
    public Typeface createFromResourcesFontFile(
            Context context, Resources resources, int id, String path, int style) {
        final File tmpFile = TypefaceCompatUtil.getTempFile(context);
        if (tmpFile == null) {
            return null;
        }
        try {
            if (!TypefaceCompatUtil.copyToFile(tmpFile, resources, id)) {
                return null;
            }
            return Typeface.createFromFile(tmpFile.getPath());
        } catch (RuntimeException e) {
            // This was thrown from Typeface.createFromFile when a Typeface could not be loaded.
            // such as due to an invalid ttf or unreadable file. We don't want to throw that
            // exception anymore.
            return null;
        } finally {
            tmpFile.delete();
        }
    }

这个方法感觉也没啥特殊的,也就是创建了字体的tmpFile,然后从这个文件去加载字体,最大的区别也就是这个方法是没有创建fontFamily的。

很可惜TypefaceCompatBaseImpl类不是public的,并且TypefaceCompatUtil也设置了包内可见,我们不能直接使用它们,如果实在想加载这个有问题的字体包的话复制一下里面的代码也不是不可以。由此我也想到了给androidxfix这个bug的办法,那就是:在>=TypefaceCompatApi26Impl的分支里面如果加载字体异常走到catch分支了,就尝试使用TypefaceCompatBaseImplcreateFromResourcesFontFile方法。

java 复制代码
//TypefaceCompatApi29Impl
public @Nullable Typeface createFromResourcesFontFile(
        Context context, Resources resources, int id, String path, int style) {
    FontFamily family = null;
    Font font = null;
    try {
        font = new Font.Builder(resources, id).build();
        family = new FontFamily.Builder(font).build();
        return new Typeface.CustomFallbackBuilder(family)
                // Set font's style to the display style for backward compatibility.
                .setStyle(font.getStyle())
                .build();
    } catch (Exception e) {
        Log.w(TAG, "Font load failed", e);
      	//return null;
        return super.createFromResourcesFontFile(context, resources, id, path, style);
    }
}
java 复制代码
//TypefaceCompatApi26Impl
@Override
public @Nullable Typeface createFromResourcesFontFile(
        Context context, Resources resources, int id, String path, int style) {
    if (!freeze(fontFamily)) {
      	//return null;
        return super.createFromResourcesFontFile(context, resources, id, path, style);
    }
    return createFromFamiliesWithDefault(fontFamily);
}

就这样,我又给androidx提交了一个PR。

但是好像由于官方不接受外部上传的字体包,也可能因为这个bug并不属于androix,而应该属于AOSP,所以这个PR好像也被搁置了。

顺带再分享一下如何给androidx贡献代码。详细的步骤androidxCONTRIBUTING.md里面都有写,简单的来说就是:先cd到你要贡献代码的playground文件夹下,然后执行./gradlew studio命令,然后它会自己下载对应匹配的Android StudioGradle,接着就等着同步成功,给androidx修复bug或提交新的api,最后一定要记得创建好自己的单元测试方法,单测很重要。

而且这次我在运行androidx项目的时候也遇到了个很离奇的问题,搞了好久,就是我只要运行ResourcesCompatTest里面的任何单元测试都会得到下面的报错信息:

shell 复制代码
Could not determine the dependencies of task ':core:core:compileReleaseAndroidTestJavaWithJavac'.
> Could not resolve all dependencies for configuration ':core:core:releaseAndroidTestCompileClasspath'.
   > Could not select a variant of project :lifecycle:lifecycle-runtime that matches the consumer attributes.
      > Could not find lifecycle-runtime.aar (project :lifecycle:lifecycle-runtime).

Possible solution:
 - Declare repository providing the artifact, see the documentation at https://docs.gradle.org/current/userguide/declaring_repositories.html

但是运行其他类的单元测试方法又是没问题的,直到了我注释掉了testutils/testutils-lifecycle/build.gradle下的lifecycle-runtime依赖,感觉应该是循环依赖导致的问题。

希望有能力的大佬能帮AOSP修好这个bug,好了,完结撒花✿✿ヽ(°▽°)ノ✿。

相关推荐
小猫猫猫◍˃ᵕ˂◍6 小时前
备忘录模式:快速恢复原始数据
android·java·备忘录模式
CYRUS_STUDIO8 小时前
使用 AndroidNativeEmu 调用 JNI 函数
android·逆向·汇编语言
梦否8 小时前
【Android】类加载器&热修复-随记
android
徒步青云8 小时前
Java内存模型
android
今阳8 小时前
鸿蒙开发笔记-6-装饰器之@Require装饰器,@Reusable装饰器
android·app·harmonyos
-优势在我13 小时前
Android TabLayout 实现随意控制item之间的间距
android·java·ui
hedalei13 小时前
android13修改系统Launcher不跟随重力感应旋转
android·launcher
Indoraptor14 小时前
Android Fence 同步框架
android
峥嵘life15 小时前
DeepSeek本地搭建 和 Android
android