前言

本次参加开源鸿蒙跨平台开发学习活动,选择了 React Native 开发 HarmonyOS技术栈,在学习的同时顺便整理成一份系列笔记,记录从环境到开发的全过程。本篇作为第10篇,在前几篇文章中,我们已经完成了 React Native 项目在 OpenHarmony 上的环境搭建、基础组件使用、页面路由、仓库文件tree的获取和显示等内容。

本篇主要实现在仓库页面下半区域展示当前仓库的README.md说明文档内容,我们需要实现一个Markdown渲染器,将Markdown语法转换为原生UI组件,提供良好的阅读体验。

README 是一个项目的说明书,也是开发者判断项目质量的重要依据。
尤其在移动端应用里,如果能直接查看 README,不但更方便,也能让整个仓库浏览体验更完整。

不过在 HarmonyOS + React Native 的环境下渲染 README,并不是简单 copy PC Web 上的方案。主要难点包括:

  1. GitCode / AtomGit 的 README 文件是通过 contents API 返回的 Base64 内容,需要手动解码。

  2. React Native 在 HarmonyOS 环境中没有默认的 Markdown 组件,需要自定义渲染逻辑。

  3. HarmonyOS 的 UI 渲染机制和 Android、iOS 有一些小差异,例如 Scroll、布局、字体等,需要稍作处理。

所以本文重点不是讲“如何用库渲染 Markdown”,
而是讲 如何构建一个适合 HarmonyOS 环境的轻量级 Markdown 渲染体系,包含:
接口封装、Base64 解码、Markdown 解析、文本排版、样式管理等完整流程。

一、准备接口与数据类型

GitCode/AtomGit 的内容接口返回数据格式遵守 GitLab API 规范,字段比较统一。
为了避免在业务层到处写 any,我将接口的关键类型先进行了定义。

为什么要先定义类型?

  • 统一管理仓库、文件内容等数据结构

  • 拥有更好的编辑器提示与类型检查

  • 后续 API 层、组件层、页面层都有明确类型约束,降低出错率

  • 对多平台版本代码(HarmonyOS/Android/iOS)有帮助,保证逻辑一致性

这些类型在项目里会被频繁使用,包括存储目录树、解析 README 内容等。

export type RepoOwner = {
  id?: string | number;
  login?: string;
  name?: string;
  avatar_url?: string;
  html_url?: string;
  type?: string;
};

export type Repo = {
  id: string | number;
  name?: string;
  path?: string;
  full_name?: string;
  description?: string;
  language?: string;
  stargazers_count?: number;
  watchers_count?: number;
  commits_count?: number;
  web_url?: string;
  html_url?: string;
  owner?: RepoOwner;
};

export type RepoFileContent = {
  type: 'file';
  encoding: 'base64';
  size: number;
  name: string;
  path: string;
  content: string;
  sha: string;
  url: string;
  html_url: string;
  download_url: string;
  _links: { self: string; html: string };
};

二、封装仓库内容请求函数

GitCode 获取 README 的方式:

GET /repos/:owner/:repo/contents/:path

接口返回的数据特点:

  • 文件内容是 Base64 编码的 Markdown

  • 如果 README 文件不存在,会返回 404

  • 网络上经常出现 “Forbidden”、“Rate limit exceeded”,因此需要处理错误提示

  • content 信息内包含 path、download_url、base64 字符串等完整信息,便于调试

在封装 API 的时候,我加入了 token、header、编码转换等处理,避免重复写同样逻辑。

import {http} from './client';
import {RepoTreeResponse} from '../types/tree';
import {RepoFileContent} from '../types/repo';

export async function fetchRepoTree(owner: string, repo: string, sha: string): Promise<RepoTreeResponse> {
  const res = await http.get<RepoTreeResponse>(`repos/${owner}/${repo}/git/trees/${sha}`, {
    headers: {Accept: 'application/json'},
    params: {access_token: ''},
  });
  return res.data;
}

export async function fetchRepoContent(owner: string, repo: string, path: string): Promise<RepoFileContent> {
  const res = await http.get<RepoFileContent>(`repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`, {
    headers: {Accept: 'application/json'},
    params: {access_token: ''},
  });
  return res.data;
}

三、实现基础 Markdown 渲染组件

Base64 → Markdown → React Native 渲染

渲染 README 是本文的重点部分。

为何不直接使用现成的 Markdown 库?

原因很简单:
在 HarmonyOS + React Native 环境中没有可直接使用的成熟 Markdown 渲染库。

很多第三方库依赖 WebView 或使用 iOS/Android Native 实现,而这些方式在 HarmonyOS 中并不完全适配。
因此,我采用的是构建一个能满足 README 基础展示需求的轻量级 Markdown 渲染器

  • 标题(#~######)

  • 普通段落

  • 无序列表(- 、*)

  • 代码块(``` 包围)

  • 链接识别与点击

  • 空行、行距、段落排版

Base64 → Markdown 解码流程

GitCode 返回的 README 内容是这样的:

{
  "type": "file",
  "encoding": "base64",
  "content": "IyBHaXRDb2RlPC9oMT4K..." 
}

其中的 content 字段需要手动 Base64 → UTF-8 转换。

Node 体系里的 Buffer 在 React Native 环境里可以正常使用,所以转换逻辑非常简单:

const decoded = Buffer.from(b64, 'base64').toString('utf-8');

解码后拿到的就是纯 Markdown 文本。

Markdown 渲染的核心原理

由于我们不使用第三方库,那么 Markdown 的渲染逻辑就需要自己拆分。

我采用的方式是:

  1. 将 Markdown 按行分割

  2. 遍历每一行,根据格式判断它属于什么类别

  3. 对不同类型生成不同的 React Native View/Text 结构

  4. 将解析后的结果加入 content 数组,最终组合成 ScrollView 渲染

例如:

  • # Title<Text style={styles.h1}>

  • - List item<Text style={styles.li}>

  • “空行” → <View style={styles.space}>

  • [Link](https://...) → 手动用正则匹配,生成可点击的链接

通过这种方式,可以做到:

  • 解析速度快

  • 结构简单易维护

  • 样式全部由 RN 统一控制

  • HarmonyOS 端渲染稳定

  • 不依赖外部组件库,更易适配

这种轻量 Markdown 渲染器虽然没有支持全部语法,但应对项目 README 已足够。

import React from 'react';
import {View, Text, StyleSheet, Linking, ScrollView} from 'react-native';

type Props = { markdown: string; loading?: boolean; error?: string; };

function renderInline(line: string): React.ReactNode {
  const parts: React.ReactNode[] = [];
  const linkRegex = /\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g;
  let lastIndex = 0; let match: RegExpExecArray | null;
  while ((match = linkRegex.exec(line))) {
    const [full, text, url] = match;
    if (match.index > lastIndex) parts.push(<Text key={`t-${lastIndex}`}>{line.slice(lastIndex, match.index)}</Text>);
    parts.push(<Text key={`l-${match.index}`} style={styles.link} onPress={() => Linking.openURL(url)}>{text}</Text>);
    lastIndex = match.index + full.length;
  }
  if (lastIndex < line.length) parts.push(<Text key={`t-end`}>{line.slice(lastIndex)}</Text>);
  return parts;
}

export default function MarkdownView({markdown, loading, error}: Props): JSX.Element {
  if (loading) return (<View style={styles.center}><Text style={styles.tip}>README 加载中...</Text></View>);
  if (error) return (<View style={styles.center}><Text style={styles.error}>README 加载失败:{error}</Text></View>);
  const lines = markdown.split(/\r?\n/);
  const content: React.ReactNode[] = [];
  let inCode = false; let codeBuf: string[] = [];
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    if (line.trim().startsWith('```')) {
      if (!inCode) { inCode = true; codeBuf = []; }
      else { inCode = false; content.push(<View key={`code-${i}`} style={styles.codeBox}><Text style={styles.codeText}>{codeBuf.join('\n')}</Text></View>); }
      continue;
    }
    if (inCode) { codeBuf.push(line); continue; }
    if (/^#{1,6}\s/.test(line)) {
      const level = (line.match(/^#+/)?.[0].length) || 1;
      const text = line.replace(/^#{1,6}\s*/, '');
      content.push(<Text key={`h-${i}`} style={[styles.h, styles[`h${level}` as keyof typeof styles]]}>{text}</Text>);
      continue;
    }
    if (/^\s*[-*]\s+/.test(line)) {
      const text = line.replace(/^\s*[-*]\s+/, '• ');
      content.push(<Text key={`li-${i}`} style={styles.li}>{renderInline(text)}</Text>);
      continue;
    }
    if (line.trim() === '') { content.push(<View key={`sp-${i}`} style={styles.space} />); continue; }
    content.push(<Text key={`p-${i}`} style={styles.p}>{renderInline(line)}</Text>);
  }
  return <ScrollView style={styles.container} contentContainerStyle={styles.content}>{content}</ScrollView>;
}

const styles = StyleSheet.create({
  container: {flex: 1},
  content: {paddingHorizontal: 16, paddingBottom: 24},
  center: {flex: 1, alignItems: 'center', justifyContent: 'center'},
  tip: {color: '#666', fontSize: 14}, error: {color: '#d00', fontSize: 14},
  h: {fontWeight: '700', marginTop: 12},
  h1: {fontSize: 22}, h2: {fontSize: 20}, h3: {fontSize: 18}, h4: {fontSize: 16}, h5: {fontSize: 15}, h6: {fontSize: 14},
  p: {fontSize: 14, color: '#333', lineHeight: 20, marginTop: 8},
  li: {fontSize: 14, color: '#333', lineHeight: 20, marginTop: 6},
  space: {height: 8}, link: {color: '#007aff'},
  codeBox: {marginTop: 10, backgroundColor: '#f6f8fa', borderRadius: 6, padding: 10},
  codeText: {fontSize: 13, color: '#333', fontFamily: 'Courier'},
});

四、在探索页读取并渲染 README

有了 API 有了渲染组件后,就可以在页面加载时请求 README 并渲染。

流程大概如下:

页面加载 → 调用 fetchRepoContent → 获取 Base64 内容 → 解码为 Markdown → 传入 MarkdownView → 渲染

合理的加载策略

为了避免页面卡顿,我将 README 的加载放在 useEffect 中异步执行:

  • 页面先展示“仓库信息”

  • README 加载中展示“loading”提示

  • 加载成功后显示完整文档

  • 加载失败给出错误提示(常见:404、权限限制)

这样的方式让用户体验更自然。

import {fetchRepoTree, fetchRepoContent} from '../api/repos';
import MarkdownView from '../components/MarkdownView';

const [readme, setReadme] = useState('');
const [readmeLoading, setReadmeLoading] = useState(false);
const [readmeError, setReadmeError] = useState('');

useEffect(() => {
  setReadmeLoading(true);
  setReadmeError('');
  fetchRepoContent(owner, repo, 'README.md')
    .then(res => {
      const b64 = res.content || '';
      const decoded = /* base64 -> markdown */ '';
      setReadme(decoded);
    })
    .catch(e => setReadmeError(String(e?.message || e)))
    .finally(() => setReadmeLoading(false));
}, [owner, repo]);

<View style={styles.topHalf}>
  {/* 目录树 FlatList 保留 */}
</View>
<View style={styles.bottomHalf}>
  <Text style={styles.readmeTitle}>README</Text>
  <MarkdownView markdown={readme} loading={readmeLoading} error={readmeError} />
</View>

五、界面布局与样式处理

在 UI 布局上,我希望仓库详情页上下结构清晰:

  • 上半部分展示“目录树”

  • 下半部分展示 README

因此采用了两个区域:

┌──────────────────────┐
│   仓库结构/目录树     │(flex:1)
└──────────────────────┘
│      README 内容      │(flex:1)
└──────────────────────┘

这样做的好处是:

  • 用户可以同时浏览目录和 README

  • 符合代码仓库常见的布局习惯

  • 在 HarmonyOS 平板上效果非常好(左右增加空间)

  • 适合后续扩展,例如切换 Tab(Issues / Releases / Wiki 等)

Markdown 区域使用 ScrollView 可以适应各种长度的内容。

六、运行与调试(HarmonyOS)

1. 启动 Metro

Metro 是 JS bundle 的提供者:

npm run start

2. 端口映射(必须)

HarmonyOS 模拟器或设备默认无法直接访问 8081,需要端口转发:

hdc rport tcp:8081 tcp:8081

不做这个步骤,页面就会一直停在白屏。

3. 打离线包(可选)

正式打包 HarmonyOS App 时需要构建 bundle:

npx react-native bundle-harmony --dev false

生成的 bundle 会被塞到 HAP 包中。

整个流程很简单,但要注意:每次调试前确保 Metro 与端口映射正常

以上就是我们开发完成后的最终效果。还是不错的~~~

总结

本篇我们完整实现了 GitCode README 在 HarmonyOS 下的渲染能力。从接口封装,到 Base64 解码,再到 Markdown 渲染组件和页面布局,整个流程看似简单,但实际涉及非常多细节。

你会发现:

  • HarmonyOS 环境对 React Native 的兼容度比想象中更好

  • 自己写一个轻量 Markdown 渲染器其实并不复杂

  • 只要把原理梳理清楚,渲染 README 这种场景完全可以掌控

  • 代码结构清晰后可扩展性也非常高(后续可以支持 TOC、图片、表格等)

最终效果是一个完全跨平台,并能在 HarmonyOS 上流畅渲染 README 的 GitCode 浏览体验。

如果你在 React Native + HarmonyOS 的开发中遇到更多适配问题,也欢迎继续交流,我会继续在系列里整理经验。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐