返回

Android NDK 按名称查找系统字体路径/数据的 3 种方法

Android

好的,这是博客文章内容:

Android 系统字体查询:如何通过 Family Name 或 PostScript Name 获取字体文件路径/数据

在 Android 应用开发中,有时咱们需要根据字体的 Family Name(家族名)或者 PostScript Name 来查找系统里安装的某个特定字体,目的是拿到它的文件路径,或者直接读取它的数据(Buffer)。尤其是在 NDK(C/C++)环境里做图形渲染或者文本处理时,这需求挺常见的。

不过,这事儿没想象中那么直接。

你可能试过 NDK 的 AFontMatcher_match 接口,但会发现它主要认 W3C 标准里的那几个通用字体族名(比如 'serif', 'sans-serif', 'monospace'),并不能让你用具体的 'Arial' 或 'HelveticaNeue-Bold' 这种精确的名字去查找。如果想在 C/C++ 层直接拿到特定字体文件,这条路似乎走不太通。

Java/Kotlin 层那边呢? Typeface.create(familyName, style) 这个方法确实可以用家族名来创建一个 Typeface 对象。但问题又来了:创建出来的 Typeface 对象,似乎没有提供公开的方法让你反问它:“嘿,你是从哪个字体文件加载来的?” 或者 “把你的原始字体数据给我一份?”。这就导致我们还是没法直接获取到需要的文件路径或 Buffer。

还有 android.graphics.fonts.Font 这个类(API 26+),它能代表一个字体文件资源,但它似乎更侧重于和加载字体,好像也没有直接提供通过 Family Name 或 PostScript Name 来 查询 系统中已安装字体并返回具体文件信息的功能。

那么,面对这个需求,到底该怎么办呢?

为啥这事儿有点绕?

主要是因为 Android 系统对字体的管理和使用做了几层封装:

  1. 配置驱动: 系统安装了哪些字体、它们的各种样式(粗体、斜体等)以及对应的字体文件(可能是 .ttf, .otf, 甚至是 .ttc 字体集合文件)是靠配置文件(主要是 /system/etc/fonts.xml 以及可能的厂商或产品覆盖配置)来定义的。应用通常不直接跟这些配置文件打交道。
  2. API 抽象: 无论是 Java/Kotlin API (Typeface, Font) 还是 NDK API (AFontMatcher),它们都提供了一个更高层次的抽象接口。这些接口隐藏了底层的字体文件路径和具体的匹配逻辑。这种设计有它的好处,比如让应用代码更稳定,不因系统字体配置的变动而轻易崩溃,但也限制了我们直接获取底层信息的能力。
  3. 缺乏直接查询接口: Android SDK/NDK 并没有提供一个像“给我系统里所有 Family Name 为 'XXX' 或 PostScript Name 为 'YYY' 的字体文件路径”这样的直接查询 API。AFontMatcher_match 只能按通用族名和大致样式来匹配一个“最合适”的字体,而不是精确查找。

弄清楚了原因,我们就可以探索一些可行的解决方案了。

有哪些路子可以试试?

虽然没有官方的“一键查询”API,但通过一些间接的方法或者深入系统底层,还是能达到目的的。下面列出几种思路,各有优劣。

方法一:硬核解析 fonts.xml (和它的兄弟们)

这是最接近系统底层字体管理机制的方式。

原理与作用:

Android 系统通过一系列 XML 文件来配置字体。最核心的是 /system/etc/fonts.xml。这个文件定义了字体家族(family)、不同样式(weight, style)对应的字体文件路径,以及一些别名(alias)和备用字体(fallback)规则。通过直接读取并解析这个(或相关的)XML 文件,我们就能建立起字体名称到文件路径的映射关系。

操作步骤:

  1. 定位配置文件:

    • 主要的配置文件通常是 /system/etc/fonts.xml
    • 但也可能存在覆盖或补充的配置文件,路径可能包括:
      • /system/product/etc/fonts_customization.xml
      • /vendor/etc/fonts.xml
      • 其他 OEM 自定义路径。
        查找逻辑可能需要参考 Android 源码中 FontManager 或类似部分的实现来确定准确的加载顺序和路径。
  2. 读取与解析 XML:

    • 你需要有权限读取这些系统文件。在普通应用中直接读取 /system 分区的文件通常是不允许的,这可能需要 root 权限,或者你的应用是系统应用/拥有特殊权限。如果是纯 NDK 环境,可以使用标准 C/C++ 文件 I/O 操作配合 XML 解析库。
    • 选择一个 XML 解析库:
      • C/C++: 可以使用 libxml2 (功能强大但稍复杂) 或 TinyXML2 (轻量级,易于集成) 等。
      • Java/Kotlin: 可以使用 XmlPullParser (Android 内建) 或其他第三方库。如果你的主要逻辑在 C++,可以通过 JNI 调用 Java 代码来解析。
    • 解析 XML 结构:你需要关注 <family> 标签(定义字体家族),其下的 <font> 标签(定义具体样式对应的字体文件)。<font> 标签通常包含 weight (字重)、style ('normal' or 'italic') 属性,以及标签内容(字体文件名,通常是相对于 /system/fonts/ 目录的路径)。<alias> 标签定义了别名。
    <!-- fonts.xml 示例片段 -->
    <familyset version="22">
        <family name="sans-serif">
            <font weight="400" style="normal">Roboto-Regular.ttf</font>
            <font weight="700" style="normal">Roboto-Bold.ttf</font>
            <font weight="400" style="italic">Roboto-Italic.ttf</font>
            <font weight="700" style="italic">Roboto-BoldItalic.ttf</font>
        </family>
        <family name="my-custom-font">
            <font weight="400" style="normal" index="0">MyFontCollection.ttc</font> <!-- TTC 文件,使用索引 -->
        </family>
        <alias name="arial" to="sans-serif" weight="400" />
        <!-- ... 更多字体和 fallback 配置 ... -->
    </familyset>
    
  3. 建立映射并查询:

    • 在解析过程中,构建一个数据结构(比如 C++ 的 std::map 或 Java 的 HashMap),存储 Family Name 到其包含的字体样式和文件路径的映射。
    • 注意 PostScript Name 通常不直接出现在 fonts.xml 中。要通过 PostScript Name 查询,你可能需要:
      • 解析所有 fonts.xml 中列出的字体文件,提取它们的 PostScript Name(这需要用到字体解析库,见方法三),然后建立 PostScript Name 到文件路径的映射。
      • 或者,先通过 Family Name 找到候选字体文件,再解析这些文件确认 PostScript Name。
    • 当需要查询时,根据提供的 Family Name 或 PostScript Name 在你构建的映射中查找对应的文件路径。路径通常需要拼接上字体目录前缀(如 /system/fonts/)。

代码示例 (C++ 使用 TinyXML2 概念):

#include "tinyxml2.h"
#include <string>
#include <vector>
#include <map>
#include <iostream>

// 简化结构体,实际可能需要更复杂来处理 weight/style/index
struct FontEntry {
    std::string filePath;
    int weight;
    std::string style;
    int ttcIndex; // 对于 TTC 文件
};

std::map<std::string, std::vector<FontEntry>> fontMap;

void parseFontsXml(const char* xmlPath) {
    tinyxml2::XMLDocument doc;
    if (doc.LoadFile(xmlPath) != tinyxml2::XML_SUCCESS) {
        std::cerr << "Error loading font XML: " << xmlPath << std::endl;
        return;
    }

    tinyxml2::XMLElement* root = doc.FirstChildElement("familyset");
    if (!root) return;

    for (tinyxml2::XMLElement* familyElement = root->FirstChildElement("family");
         familyElement;
         familyElement = familyElement->NextSiblingElement("family")) {

        const char* familyName = familyElement->Attribute("name");
        if (!familyName) continue;

        std::vector<FontEntry> entries;
        for (tinyxml2::XMLElement* fontElement = familyElement->FirstChildElement("font");
             fontElement;
             fontElement = fontElement->NextSiblingElement("font")) {

            const char* fontPath = fontElement->GetText();
            if (!fontPath) continue;

            FontEntry entry;
            entry.filePath = "/system/fonts/" + std::string(fontPath); // 假定基础路径
            entry.weight = fontElement->IntAttribute("weight", 400);
            entry.style = fontElement->Attribute("style", "normal");
            entry.ttcIndex = fontElement->IntAttribute("index", 0); // 处理 TTC index
            entries.push_back(entry);
        }
        fontMap[std::string(familyName)] = entries;

        // 处理 alias 等其他逻辑...
    }
    // 处理 alias 标签逻辑...
}

int main() {
    parseFontsXml("/system/etc/fonts.xml");
    // ... 在 fontMap 中根据 familyName 查询 ...

    // 如果要根据 PostScript Name 查询,需要额外步骤(见方法三)
    // 比如遍历 fontMap 中所有 filePath,用 FreeType 打开解析出 PostScript Name 再建立反向映射

    return 0;
}

安全与注意事项:

  • 权限问题: 这是最大的障碍。普通应用无法随意读取系统分区文件。此方法更适用于具有系统权限或 root 权限的环境,或者在设备固件开发阶段使用。
  • 稳定性风险: fonts.xml 的路径、格式、内容可能随着 Android 版本更新或 OEM 定制而改变。硬编码解析逻辑可能在不同设备或系统版本上失效。
  • 复杂性: fonts.xml 的完整结构比示例复杂,包含 fallback chain、language-specific overrides 等,完整解析有一定工作量。
  • 字体文件格式: 需要能处理 .ttf, .otf, 以及 .ttc (TrueType Collection) 文件(后者包含多个字体,需要 index 属性)。

进阶使用:

  • 实现完整的 alias 和 fallback 解析逻辑,模拟系统的字体选择行为。
  • 结合字体文件解析(方法三),构建更完整的 Family Name/PostScript Name 到文件路径的映射。

方法二:曲线救国 - 借助 Java API SystemFonts (API 29+)

如果你的应用可以调用 Java/Kotlin 代码(即使主要逻辑在 C++,也可以通过 JNI),并且目标 Android 版本是 9 (API 29) 或更高,那么有一个更现代、更标准的途径。

原理与作用:

Android 9 (Pie, API 29) 引入了 android.graphics.fonts.SystemFonts 类。这个类提供了一个方法 getAvailableFonts(),可以返回当前系统上所有可用的字体集合(Set<Font>)。每个 Font 对象代表一个系统字体文件或其中的一个子字体(对于 TTC)。Font 对象可以提供字体文件的 Buffer 或文件路径(如果可能且有权限)。

操作步骤:

  1. 调用 SystemFonts.getAvailableFonts() (Java/Kotlin):

    import android.graphics.fonts.Font;
    import android.graphics.fonts.SystemFonts;
    import android.os.ParcelFileDescriptor;
    import java.io.File;
    import java.io.IOException;
    import java.nio.ByteBuffer;
    import java.util.Set;
    
    // ... 在你的 Activity 或 Service 中 ...
    
    Set<Font> availableFonts = SystemFonts.getAvailableFonts();
    
  2. 遍历 Font 对象并获取信息:
    遍历返回的 Set<Font>。对于每个 Font 对象:

    • 尝试获取文件路径:File fontFile = font.getFile();
      • 这可能返回 null,比如字体不是直接来自文件系统,或者应用没有读取该文件的权限。
      • 即使返回了 File 对象,尝试访问它仍然可能因权限不足而失败。需要 READ_EXTERNAL_STORAGE 权限(对于某些位置),但系统字体通常在 /system 分区,普通应用仍无法读取。
    • 尝试获取字体数据 Buffer:ByteBuffer fontBuffer = font.getBuffer();
      • 这通常更可靠,可以直接在内存中获取字体数据,避免了文件路径和权限的麻烦。返回的 ByteBuffer 是只读的。
    • 获取 TTC 索引(如果字体来自 TTC 文件):int ttcIndex = font.getTtcIndex();
    • 获取字体样式信息(FontStyle):FontStyle style = font.getStyle(); 可以得到 weight 和 italic 信息。
  3. 匹配 Family Name / PostScript Name:
    这一步是关键,因为 Font 对象本身 并不直接提供 Family Name 或 PostScript Name 字符串。你需要:

    • 获取到 fontFile (如果成功) 或 fontBuffer
    • 使用字体解析库(如 FreeType,见方法三)来解析这个文件或 Buffer,提取出内部的 Family Name 和 PostScript Name。
    • 将提取出的名称与你的目标名称进行比较。
  4. 在 C++ 中使用 (通过 JNI):
    如果你的核心逻辑在 C++,你需要:

    • 编写 Java/Kotlin JNI 辅助函数,调用 SystemFonts.getAvailableFonts(),遍历结果。
    • 在 JNI 函数中,对于每个匹配(或所有)的 Font
      • 调用 font.getBuffer() 获取 ByteBuffer
      • ByteBuffer 的地址和大小传递给 C++ 层。 C++ 层可以直接使用这个内存区域(通过 env->GetDirectBufferAddress()env->GetDirectBufferCapacity())。
      • 或者,如果获取到了 File 对象并且 C++ 层有权限访问,可以将文件路径字符串传递给 C++。
    • C++ 层接收到 Buffer 或路径后,用 FreeType 等库进行解析和名称匹配。

代码示例 (Java/Kotlin 概念):

// ... (需要结合字体解析库, 伪代码示意) ...

String targetFamilyName = "YourTargetFamilyName";
String targetPostScriptName = "YourTargetPostScriptName";
ByteBuffer foundFontBuffer = null;
String foundFontPath = null;
int foundTtcIndex = 0;

Set<Font> availableFonts = SystemFonts.getAvailableFonts();
for (Font font : availableFonts) {
    ByteBuffer buffer = null;
    String path = null;
    File file = null;

    try {
        buffer = font.getBuffer(); // 优先尝试获取 Buffer
    } catch (IOException e) {
        // Buffer 获取失败,尝试获取 File
        file = font.getFile();
        if (file != null) {
            path = file.getAbsolutePath();
            // 注意:即使拿到路径,后续也可能无权读取
        }
    }

    if (buffer != null || path != null) {
        int ttcIndex = font.getTtcIndex();
        // *** 此处需要调用字体解析逻辑 (比如通过 JNI 调用 C++ FreeType 函数) ** *
        // String[] names = parseFontAndGetNames(buffer, path, ttcIndex);
        // String actualFamilyName = names[0];
        // String actualPostScriptName = names[1];

        // 假设 parseFontAndGetNames 返回了解析出的名称
        // if (actualFamilyName.equals(targetFamilyName) || actualPostScriptName.equals(targetPostScriptName)) {
        //     foundFontBuffer = buffer; // 如果 buffer 有效,优先使用 buffer
        //     foundFontPath = path;
        //     foundTtcIndex = ttcIndex;
        //     break; // 找到即停止,或根据需求收集所有匹配项
        // }
    }
}

if (foundFontBuffer != null || foundFontPath != null) {
    // 找到了!可以使用 foundFontBuffer 或 foundFontPath (及 ttcIndex)
    // 将其传递给 C++ NDK 层或直接使用
}

安全与注意事项:

  • API Level 要求: 仅适用于 Android 9 (API 29) 及以上版本。
  • 权限问题: font.getFile() 对系统字体的访问权限依然受限。优先使用 font.getBuffer()
  • 需要字体解析: 无法避免需要一个字体解析库(如 FreeType)来从 Buffer 或文件中提取精确的名称信息进行匹配。
  • 性能: 遍历所有系统字体并解析它们可能需要一些时间,尤其是在首次执行时。考虑缓存结果。

进阶使用:

  • 实现高效的 JNI 接口,在 C++ 层直接接收 ByteBuffer 并使用 FreeType 解析。
  • 构建缓存机制,避免每次都重新遍历和解析所有系统字体。可以将解析出的 (FamilyName, PostScriptName) -> (Buffer/Path, index) 映射缓存起来。

方法三:终极武器 - FreeType 直接扫描和解析 (NDK/C++)

如果前两种方法不适用(比如目标系统版本低于 API 29,或者需要完全在 C++ 中控制),或者你需要最精确的字体信息,可以直接使用强大的开源字体引擎 FreeType。

原理与作用:

FreeType 是一个广泛使用的、高质量、可移植的字体渲染引擎库。它能够加载、解析和渲染多种字体格式(TTF, OTF, Type1, TTC 等)。我们可以利用 FreeType 在 C++ 层直接扫描系统字体目录,打开每个字体文件,读取其内部的名称表(Name Table),从而获取到精确的 Family Name 和 PostScript Name,然后与目标名称进行比较。

操作步骤:

  1. 集成 FreeType 到你的 NDK 项目:

    • 你需要将 FreeType 库添加到你的 Android NDK 项目中。可以通过 CMake 或 ndk-build 将其源码编译或链接预编译库。
    • 确保在你的 C++ 代码中包含 FreeType 的头文件 (ft2build.h, freetype/freetype.h, freetype/ftnameid.h 等)。
  2. 确定并扫描字体目录:

    • Android 系统的字体通常位于 /system/fonts/ 目录下。但也可能存在于 /system/product/fonts/, /data/fonts/ (用户安装字体) 等位置。你需要确定要扫描的目录列表。
    • 使用 C/C++ 的目录遍历功能(如 POSIX 的 dirent.h 或 C++17 的 <filesystem>) 来列出这些目录下的所有文件。
  3. 使用 FreeType 打开和解析字体:

    • 初始化 FreeType 库: FT_Library library; FT_Init_FreeType(&library);
    • 遍历找到的每个字体文件(通常检查 .ttf.otf 后缀,也要处理 .ttc)。
    • 对每个文件路径,使用 FT_New_Face(library, filepath, face_index, &face); 来打开字体。
      • 对于 .ttf.otf 文件,face_index 通常是 0。
      • 对于 .ttc (TrueType Collection) 文件,一个文件可能包含多个字体。你需要先用 FT_Open_Face 探测(传入 -1 作为 index 可以获取集合中字体数量),然后用 0 到 num_faces - 1 的索引多次调用 FT_New_Face 来访问集合中的每个字体。
    • 检查返回值确保字体加载成功。
  4. 提取名称信息:

    • 加载成功后,FT_Face face 对象就包含了字体信息。
    • Family Name: 通常可以直接访问 face->family_name
    • Style Name: 可以访问 face->style_name
    • PostScript Name: 这个通常不直接在 face 结构体的顶层字段。你需要查询 SFNT Name Table。使用 FT_Get_Sfnt_Name_Count() 获取名称记录数量,然后遍历每条记录 FT_Get_Sfnt_Name()。查找 platform_id, encoding_id, language_id 合适的组合(比如 Windows Unicode BMP 或 Mac Roman),并且 name_idTT_NAME_ID_POSTSCRIPT_NAME (值为 6) 的记录。找到后,其 string 字段就是 PostScript Name。
  5. 比较与返回:

    • 将提取出的 Family Name 或 PostScript Name 与你的目标名称进行比较。
    • 如果匹配成功,你已经有了该字体的 filepath。如果你需要的是 Buffer,可以使用 C/C++ 文件操作读取该文件内容。或者,如果想让 FreeType 管理内存,可以不关闭 FT_Face,并用 FreeType 的 API 渲染或提取字形数据。对于只需要 Buffer 的情况,更高效的方式是先读文件到内存 Buffer,再用 FT_New_Memory_Face() 从内存加载字体进行解析。
  6. 清理资源:

    • 处理完一个 FT_Face 后,用 FT_Done_Face(face) 关闭它。
    • 所有操作完成后,用 FT_Done_FreeType(library) 清理 FreeType 库。

代码示例 (C++ 使用 FreeType 概念):

#include <ft2build.h>
#include FT_FREETYPE_H
#include FT_SFNT_NAMES_H // For SFNT name access
#include <string>
#include <vector>
#include <dirent.h> // For directory scanning
#include <iostream>

bool findFontByName(const char* targetName, bool isPostScriptName, std::string& outFilePath, long& outFaceIndex) {
    FT_Library library;
    if (FT_Init_FreeType(&library)) {
        std::cerr << "Error initializing FreeType library." << std::endl;
        return false;
    }

    const char* fontDirs[] = {"/system/fonts"}; // Add other potential dirs
    for (const char* dirPath : fontDirs) {
        DIR* dir = opendir(dirPath);
        if (!dir) continue;

        struct dirent* entry;
        while ((entry = readdir(dir)) != NULL) {
            std::string fileName = entry->d_name;
            if (fileName == "." || fileName == "..") continue;
            // Basic check for font extensions
            if (fileName.length() > 4 && (fileName.substr(fileName.length() - 4) == ".ttf" || fileName.substr(fileName.length() - 4) == ".otf" || fileName.substr(fileName.length() - 4) == ".ttc"))
            {
                std::string fullPath = std::string(dirPath) + "/" + fileName;
                FT_Face face;

                // Need to handle TTC: first detect, then loop through indices
                FT_Error error = FT_New_Face(library, fullPath.c_str(), -1, &face); // Use -1 to detect number of faces
                 if (!error) {
                    long numFaces = face->num_faces;
                    FT_Done_Face(face); // Close the 'detector' face

                    for (long index = 0; index < numFaces; ++index) {
                        error = FT_New_Face(library, fullPath.c_str(), index, &face);
                        if (!error) {
                            std::string currentName;
                            if (isPostScriptName) {
                                // Find PostScript Name (Name ID 6)
                                FT_UInt count = FT_Get_Sfnt_Name_Count(face);
                                for (FT_UInt i = 0; i < count; ++i) {
                                    FT_SfntName name;
                                    if (FT_Get_Sfnt_Name(face, i, &name) == 0) {
                                        if (name.name_id == TT_NAME_ID_POSTSCRIPT_NAME) {
                                             // Be careful with encoding, may need conversion
                                             currentName.assign(reinterpret_cast<char*>(name.string), name.string_len);
                                             // Might need platform/encoding check for robustness
                                             break; // Assume first found is OK for simplicity
                                        }
                                    }
                                }
                            } else {
                                // Get Family Name
                                if (face->family_name) {
                                     currentName = face->family_name;
                                }
                            }

                            if (!currentName.empty() && currentName == targetName) {
                                outFilePath = fullPath;
                                outFaceIndex = index;
                                FT_Done_Face(face);
                                closedir(dir);
                                FT_Done_FreeType(library);
                                return true; // Found!
                            }
                            FT_Done_Face(face); // Close current face index
                        }
                    }
                }
            }
        }
        closedir(dir);
    }

    FT_Done_FreeType(library);
    return false; // Not found
}

int main() {
    std::string filePath;
    long faceIndex;
    if (findFontByName("Roboto-Regular", true, filePath, faceIndex)) { // Find by PostScript Name
         std::cout << "Found font: " << filePath << " at index " << faceIndex << std::endl;
         // Now you can read the file into a buffer or use FreeType further
    } else {
         std::cout << "Font not found." << std::endl;
    }
    return 0;
}

安全与注意事项:

  • FreeType 依赖: 需要将 FreeType 正确集成到你的 NDK 构建系统中。
  • 目录扫描权限: 同样,应用可能没有权限扫描所有系统目录。但相比直接解析 XML,扫描和尝试打开文件可能限制略少些(虽然读取 /system/fonts 通常仍受限)。
  • 性能: 扫描目录、打开和解析大量字体文件可能比较耗时。首次启动时执行此操作可能会感觉慢。
  • 错误处理: FreeType 的函数调用都需要仔细检查返回值并处理错误。
  • TTC/OTC 处理: 务必正确处理字体集合文件,遍历其中的每个子字体。
  • 名称编码: 从 SFNT Name Table 读取的名称字符串可能有不同的编码,需要正确处理(UTF-16BE 转 UTF-8 是常见的)。

进阶使用:

  • 从内存加载: 先将文件读入内存 Buffer,然后使用 FT_New_Memory_Face() 可以避免文件句柄问题,也可能更快(如果 Buffer 已缓存)。
  • 实现缓存: 对扫描和解析结果进行缓存。可以在首次运行时构建一个完整的 (Name -> Path/Index) 映射,并保存起来供后续快速查询。
  • 优化名称查找: 对于 PostScript Name,可以更精确地指定 platform_id, encoding_id, language_id 来查找,而不是简单取第一个 ID 6 的记录。

选择哪种方法取决于你的具体需求:项目环境(NDK/Java/Kotlin混合?)、目标 Android 版本、是否能接受引入 FreeType 这样的依赖、对性能和稳定性的要求,以及应用是否有特殊权限等因素。希望以上分析和方案能帮你找到解决问题的钥匙。