「D12-D16」功能拓展与深度优化
欢迎大家加入[开源鸿蒙跨平台开发者社区](https://openharmonycrossplatform.csdn.net),一起共建开源鸿蒙跨平台生态。
目录
services/gitcode_api_service.dart
5. Providers / ViewModel 层(Riverpod)
providers/repo_detail_providers.dart
1. 页面架构总览(上下两大区域)
RepoDetailPage
-
上部区域:目录树 / 文件列表
-
左侧树 or 纵向树形列表(递归渲染)
-
支持点击文件夹展开/收起、进入下一级
-
-
下部主区域:README.md Markdown 原生渲染
-
拉取 README 内容
-
markdown -> 原生 widget(非网页)
-
数据完全通过 GitCode OpenAPI 获取、JSON 解析、Flutter Widget 渲染。
目录树接口:GET /api/v5/repos/{owner}/{repo}/git/trees/{sha}?recursive=1 (GitCode)
文件/目录内容接口:GET /api/v5/repos/{owner}/{repo}/contents/{path} (GitCode)
文件 Blob 接口:GET /api/v5/repos/{owner}/{repo}/git/blobs/{sha}(base64 content) (GitCode)
2. 依赖(Markdown + 代码高亮)
pubspec.yaml 增加:
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
dio: ^5.7.0
flutter_markdown: ^0.7.3
flutter_highlight: ^0.7.0
highlight: ^0.7.0
-
flutter_markdown:Markdown 原生渲染(README) -
flutter_highlight + highlight:代码语法高亮(加分项)
3. Model 层
models/repo_tree_node.dart
class RepoTreeNode {
final String path; // like "docs/README.md"
final String type; // "tree" or "blob"
final String sha;
final int? size;
RepoTreeNode({
required this.path,
required this.type,
required this.sha,
this.size,
});
String get name => path.split('/').last;
bool get isDir => type == 'tree';
bool get isFile => type == 'blob';
factory RepoTreeNode.fromJson(Map<String, dynamic> json) {
return RepoTreeNode(
path: json['path'] ?? '',
type: json['type'] ?? '',
sha: json['sha'] ?? '',
size: json['size'],
);
}
}
models/readme.dart
class ReadmeData {
final String content; // decoded markdown text
final String sha;
ReadmeData({required this.content, required this.sha});
}
4. API Service 层
services/gitcode_api_service.dart
import 'dart:convert';
import 'package:dio/dio.dart';
import '../models/repo_tree_node.dart';
import '../models/readme.dart';
class GitCodeApiService {
final Dio _dio;
GitCodeApiService({
Dio? dio,
String? accessToken,
}) : _dio = dio ??
Dio(BaseOptions(
baseUrl: 'https://api.gitcode.com/api/v5',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: accessToken == null
? {}
: {'Authorization': 'Bearer $accessToken'},
));
/// 1) 获取目录树(支持 recursive=1)
Future<List<RepoTreeNode>> fetchRepoTree({
required String owner,
required String repo,
required String sha, // branch name like "master" or "main"
bool recursive = true,
String? filePath,
}) async {
final resp = await _dio.get(
'/repos/$owner/$repo/git/trees/$sha',
queryParameters: {
'recursive': recursive ? 1 : 0,
if (filePath != null) 'file_path': filePath,
},
);
final tree = (resp.data['tree'] as List?) ?? [];
return tree.map((e) => RepoTreeNode.fromJson(e)).toList();
}
/// 2) 获取某路径内容(目录或文件信息)
Future<dynamic> fetchContents({
required String owner,
required String repo,
required String path, // "" for root
String? ref, // branch
}) async {
final resp = await _dio.get(
'/repos/$owner/$repo/contents/$path',
queryParameters: {if (ref != null) 'ref': ref},
);
return resp.data;
}
/// 3) 通过 blob sha 拉文件内容(base64)
Future<String> fetchBlobText({
required String owner,
required String repo,
required String sha,
}) async {
final resp = await _dio.get('/repos/$owner/$repo/git/blobs/$sha');
final contentBase64 = resp.data['content'] ?? '';
final bytes = base64.decode(contentBase64);
return utf8.decode(bytes);
}
/// 4) 获取 README(优先 README.md,其次 readme 等)
Future<ReadmeData?> fetchReadme({
required String owner,
required String repo,
String ref = 'master',
}) async {
// 直接走 contents 接口拿 README.md 的 sha
final candidates = ['README.md', 'readme.md', 'README.MD'];
for (final name in candidates) {
try {
final data = await fetchContents(
owner: owner,
repo: repo,
path: name,
ref: ref,
);
if (data is Map && data['sha'] != null) {
final text = await fetchBlobText(
owner: owner,
repo: repo,
sha: data['sha'],
);
return ReadmeData(content: text, sha: data['sha']);
}
} catch (_) {
// try next
}
}
return null;
}
}
5. Providers / ViewModel 层(Riverpod)
providers/repo_detail_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/gitcode_api_service.dart';
import '../models/repo_tree_node.dart';
import '../models/readme.dart';
final gitCodeApiProvider = Provider<GitCodeApiService>((ref) {
return GitCodeApiService(
accessToken: null, // 公共仓库可不填;私有仓库填 token
);
});
/// 当前目录路径(用于无刷新进入下一层)
final currentPathProvider = StateProvider<String>((ref) => '');
/// 目录树 Provider(一次拉全树 recursive=1)
final repoTreeProvider = FutureProvider.family<List<RepoTreeNode>, RepoId>(
(ref, repoId) async {
final api = ref.read(gitCodeApiProvider);
return api.fetchRepoTree(
owner: repoId.owner,
repo: repoId.repo,
sha: repoId.branch,
recursive: true,
);
},
);
/// README Provider
final readmeProvider = FutureProvider.family<ReadmeData?, RepoId>(
(ref, repoId) async {
final api = ref.read(gitCodeApiProvider);
return api.fetchReadme(
owner: repoId.owner,
repo: repoId.repo,
ref: repoId.branch,
);
},
);
class RepoId {
final String owner;
final String repo;
final String branch;
const RepoId(this.owner, this.repo, {this.branch = 'master'});
@override
bool operator ==(Object other) =>
other is RepoId &&
owner == other.owner &&
repo == other.repo &&
branch == other.branch;
@override
int get hashCode => Object.hash(owner, repo, branch);
}
6. 目录树可视化(递归渲染)
做法:recursive=1 拉到全树,前端把 path 拆成节点树,然后递归 widget。
widgets/repo_tree_view.dart
import 'package:flutter/material.dart';
import '../models/repo_tree_node.dart';
class TreeItem {
final String name;
final String fullPath;
final bool isDir;
final String sha;
final List<TreeItem> children;
bool expanded;
TreeItem({
required this.name,
required this.fullPath,
required this.isDir,
required this.sha,
this.children = const [],
this.expanded = false,
});
TreeItem copyWith({List<TreeItem>? children, bool? expanded}) => TreeItem(
name: name,
fullPath: fullPath,
isDir: isDir,
sha: sha,
children: children ?? this.children,
expanded: expanded ?? this.expanded,
);
}
/// 把 flat tree 转成层级树
List<TreeItem> buildTree(List<RepoTreeNode> nodes) {
final root = <String, TreeItem>{};
for (final n in nodes) {
final parts = n.path.split('/');
Map<String, TreeItem> cur = root;
String accPath = '';
for (int i = 0; i < parts.length; i++) {
final p = parts[i];
accPath = accPath.isEmpty ? p : '$accPath/$p';
final isLeaf = i == parts.length - 1;
if (!cur.containsKey(accPath)) {
cur[accPath] = TreeItem(
name: p,
fullPath: accPath,
isDir: isLeaf ? n.isDir : true,
sha: isLeaf ? n.sha : '',
children: [],
);
}
if (!isLeaf) {
// dive
final item = cur[accPath]!;
final childMap = {
for (final c in item.children) c.fullPath: c
};
cur = childMap;
// re-attach later by rebuild
}
}
}
// 上面写法简化过头了,重新做 robust tree:
final map = <String, TreeItem>{};
for (final n in nodes) {
final parts = n.path.split('/');
String parentPath = '';
for (int i = 0; i < parts.length; i++) {
final part = parts[i];
final currentPath =
parentPath.isEmpty ? part : '$parentPath/$part';
final isLeaf = i == parts.length - 1;
map.putIfAbsent(
currentPath,
() => TreeItem(
name: part,
fullPath: currentPath,
isDir: isLeaf ? n.isDir : true,
sha: isLeaf ? n.sha : '',
children: [],
),
);
parentPath = currentPath;
}
}
// attach children
for (final item in map.values) {
final parentPath =
item.fullPath.contains('/') ? item.fullPath.substring(0, item.fullPath.lastIndexOf('/')) : '';
if (parentPath.isNotEmpty && map.containsKey(parentPath)) {
final parent = map[parentPath]!;
parent.children.add(item);
}
}
// roots
final roots = map.values.where((i) => !i.fullPath.contains('/')).toList();
// sort
void sortRec(List<TreeItem> list) {
list.sort((a, b) {
if (a.isDir != b.isDir) return a.isDir ? -1 : 1;
return a.name.compareTo(b.name);
});
for (final i in list) sortRec(i.children);
}
sortRec(roots);
return roots;
}
class RepoTreeView extends StatefulWidget {
final List<TreeItem> roots;
final void Function(TreeItem item) onTapDir;
final void Function(TreeItem item) onTapFile;
const RepoTreeView({
super.key,
required this.roots,
required this.onTapDir,
required this.onTapFile,
});
@override
State<RepoTreeView> createState() => _RepoTreeViewState();
}
class _RepoTreeViewState extends State<RepoTreeView> {
Widget _buildNode(TreeItem item, int depth) {
final icon = item.isDir
? (item.expanded ? Icons.folder_open : Icons.folder)
: Icons.insert_drive_file;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () {
setState(() {
if (item.isDir) {
item.expanded = !item.expanded;
widget.onTapDir(item);
} else {
widget.onTapFile(item);
}
});
},
child: Padding(
padding: EdgeInsets.only(left: depth * 12.0, top: 6, bottom: 6),
child: Row(
children: [
Icon(icon, size: 18),
const SizedBox(width: 6),
Expanded(child: Text(item.name, maxLines: 1, overflow: TextOverflow.ellipsis)),
],
),
),
),
if (item.isDir && item.expanded)
...item.children.map((c) => _buildNode(c, depth + 1)),
],
);
}
@override
Widget build(BuildContext context) {
return ListView(
children: widget.roots.map((r) => _buildNode(r, 0)).toList(),
);
}
}
7. README 原生 Markdown 渲染
widgets/readme_view.dart
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
class ReadmeView extends StatelessWidget {
final String markdown;
const ReadmeView({super.key, required this.markdown});
@override
Widget build(BuildContext context) {
return Markdown(
data: markdown,
selectable: true,
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
p: Theme.of(context).textTheme.bodyMedium,
codeblockDecoration: BoxDecoration(
color: Colors.black.withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
),
),
);
}
}
8. 文件内容页 + 代码高亮(加分项)
pages/file_content_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:highlight/languages/all.dart';
import '../providers/repo_detail_providers.dart';
class FileContentPage extends ConsumerWidget {
final RepoId repoId;
final String path; // file full path
final String sha;
const FileContentPage({
super.key,
required this.repoId,
required this.path,
required this.sha,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final api = ref.read(gitCodeApiProvider);
return Scaffold(
appBar: AppBar(title: Text(path.split('/').last)),
body: FutureBuilder<String>(
future: api.fetchBlobText(
owner: repoId.owner,
repo: repoId.repo,
sha: sha,
),
builder: (context, snap) {
if (!snap.hasData) {
return const Center(child: CircularProgressIndicator());
}
final text = snap.data!;
final lang = _guessLanguage(path);
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: HighlightView(
text,
language: lang,
languages: allLanguages,
padding: const EdgeInsets.all(12),
textStyle: const TextStyle(
fontFamily: 'monospace',
fontSize: 13,
),
),
);
},
),
);
}
String? _guessLanguage(String path) {
final ext = path.split('.').last.toLowerCase();
const map = {
'dart': 'dart',
'js': 'javascript',
'ts': 'typescript',
'java': 'java',
'kt': 'kotlin',
'c': 'c',
'cc': 'cpp',
'cpp': 'cpp',
'h': 'cpp',
'py': 'python',
'md': 'markdown',
'json': 'json',
'yaml': 'yaml',
'yml': 'yaml',
'xml': 'xml',
'sh': 'bash',
};
return map[ext];
}
}
9. 仓库详情页(核心页面)
pages/repo_detail_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/repo_detail_providers.dart';
import '../widgets/repo_tree_view.dart';
import '../widgets/readme_view.dart';
import 'file_content_page.dart';
class RepoDetailPage extends ConsumerWidget {
final RepoId repoId;
const RepoDetailPage({super.key, required this.repoId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final treeAsync = ref.watch(repoTreeProvider(repoId));
final readmeAsync = ref.watch(readmeProvider(repoId));
return Scaffold(
appBar: AppBar(title: Text('${repoId.owner}/${repoId.repo}')),
body: treeAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => _ErrorView(msg: e.toString(), onRetry: () {
ref.invalidate(repoTreeProvider(repoId));
ref.invalidate(readmeProvider(repoId));
}),
data: (nodes) {
final roots = buildTree(nodes);
return Column(
children: [
// 上部:目录树
SizedBox(
height: MediaQuery.of(context).size.height * 0.45,
child: RepoTreeView(
roots: roots,
onTapDir: (item) {
// 点击目录:只展开/收起,不跳页;下方 README 不变
},
onTapFile: (item) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => FileContentPage(
repoId: repoId,
path: item.fullPath,
sha: item.sha,
),
),
);
},
),
),
const Divider(height: 1),
// 下部:README
Expanded(
child: readmeAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(
child: Text('README 加载失败:$e'),
),
data: (readme) {
if (readme == null || readme.content.trim().isEmpty) {
return const Center(child: Text('该仓库没有 README'));
}
return ReadmeView(markdown: readme.content);
},
),
),
],
);
},
),
);
}
}
class _ErrorView extends StatelessWidget {
final String msg;
final VoidCallback onRetry;
const _ErrorView({required this.msg, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Text('加载失败:$msg'),
const SizedBox(height: 8),
ElevatedButton(onPressed: onRetry, child: const Text('重试')),
]),
);
}
}
10. 使用示例(对照任务给的仓库)
比如你要展示 https://gitcode.com/openharmony/docs:
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => RepoDetailPage(
repoId: const RepoId('openharmony', 'docs', branch: 'master'),
),
),
);
页面效果与你描述一致:
上层显示目录树+文件,点目录无刷新展开,点文件进入内容页;下层显示 README。
11. 可以继续优化的点
-
局部加载而非全树
当前用 recursive=1 一次拉全树(简单、快完成)。
进阶:点击目录时按file_path再拉局部 Tree,减少大仓库流量。(GitCode) -
缓存(参考 CSDN 的 24h)
-
RepoTree、README都可落 SharedPreferences -
过期策略 24h
-
缓存优先 / 后台刷新(和你学的 Day11 一致)
-
-
目录树 UI 体验
-
当前是纵向递归树
-
可加面包屑导航(currentPathProvider)
-
文件/文件夹图标更贴近 GitCode 网页
-
-
Markdown 样式
-
支持图片/链接/表格
-
自定义 codeblock widget(结合 Highlight 做 markdown 内高亮)
-



总结
本文介绍了一个基于Flutter实现的GitCode仓库详情页应用架构。系统采用上下分区的页面布局,上部显示递归渲染的目录树结构,下部展示Markdown格式的README内容。技术实现上,通过GitCode OpenAPI获取数据,使用Riverpod进行状态管理,包含API服务层、模型层、视图层等完整架构。关键功能包括:目录树可视化(支持递归展开/收起)、README原生渲染、文件内容查看(支持代码高亮)等。项目依赖flutter_markdown和highlight等库实现核心功能,并提供了可扩展的优
更多推荐



所有评论(0)