komi-store Dev Profile 功能模块深度解析:开发者主页的领域建模、数据层实现与导航集成

原创2026-09-30 22:12:191,981 阅读
文章标签:移动开发桌面应用跨平台前端

komi-store Dev Profile 功能模块深度解析:开发者主页的领域建模、数据层实现与导航集成

本文基于 feature/dev-profile/CLAUDE.md 展开,系统讲解 komi-store(GitHub 加速计划 / komi-store,一个面向 GitHub、Codeberg 与 Forgejo 上开发者 Release 的免费开源应用商店)中「开发者资料页(Dev Profile)」功能的完整实现:从领域模型与仓库接口的定义、数据层的「后端优先 + GitHub 兜底」双通道实现,到表现层的并行加载与客户端过滤排序,再到全局导航的接入方式。读完本文,你将掌握该模块的分层架构、Result<T> 错误处理约定、平台感知的 Release 资产检测逻辑,以及它如何与首页、搜索、详情、收藏、星标等卡片入口协同。

一、功能定位:从任意仓库卡片直达开发者主页

Dev Profile 是 komi-store 中面向 GitHub 用户/开发者的个人资料视图,核心能力包括:

  • 展示头像、简介(bio)、统计信息(followers / following / public repos 等);
  • 展示该开发者名下的仓库列表,支持本地过滤与排序;
  • 支持查看 followers / following 关注关系;
  • 可从任意仓库卡片(home / search / details / favourites / starred)点击进入。

该模块采用与项目其他 feature 一致的「domain / data / presentation」三层结构(详见 feature/dev-profile/CLAUDE.md),并遵循整个项目「所有网络调用返回 Result<T>、尽量客户端过滤」的约定。

二、领域层(domain):接口契约与数据模型

2.1 核心仓库接口

原文档给出的关键接口定义如下,源码位于 feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/repository/DeveloperProfileRepository.kt:

interface DeveloperProfileRepository {
    suspend fun getDeveloperProfile(username: String): Result<DeveloperProfile>
    suspend fun getDeveloperRepositories(username: String): Result<List<DeveloperRepository>>
}

需要说明的是:当前仓库中该接口实际还包含第三个方法 suspend fun getContributionCalendar(username: String): Result<ContributionCalendar>,用于拉取过去一年的贡献日历数据(GitHub 贡献图)。三个方法全部返回 Result<T>,与文档「Both API calls return Result<T>」的约定一致,失败路径通过 Result.failure 表达,而不是抛异常。

2.2 开发者资料模型

DeveloperProfile.kt 定义如下:

data class DeveloperProfile(
    val login: String,
    val name: String?,
    val avatarUrl: String,
    val bio: String?,
    val company: String?,
    val location: String?,
    val email: String?,
    val blog: String?,
    val twitterUsername: String?,
    val publicRepos: Int,
    val publicGists: Int,
    val followers: Int,
    val following: Int,
    val createdAt: String,
    val updatedAt: String,
    val htmlUrl: String,
    val userType: String? = null,
) {
    val isOrganization: Boolean get() = userType.equals("Organization", ignoreCase = true)
}

关键点:

  • 除 login、avatarUrl、统计数字等必填字段外,多数描述性字段(name、bio、company、location、email、blog、twitterUsername)均允许为 null,与 GitHub API 的实际情况对应;
  • isOrganization 是派生属性,根据 userType 是否为 "Organization" 判断。在 ViewModel 中该标志用于决定是否加载贡献日历(组织账号通常没有个人贡献图,详见 DeveloperProfileViewModel.kt)。

2.3 开发者仓库模型

DeveloperRepository.kt 除了保留 GitHub 仓库的基础属性(id、name、fullName、description、htmlUrl、stargazersCount、forksCount、openIssuesCount、language、updatedAt)外,还额外加入了与商店业务强相关的派生字段:

val hasReleases: Boolean,
val hasInstallableAssets: Boolean,
val isInstalled: Boolean = false,
val isFavorite: Boolean = false,
val latestVersion: String? = null,
val latestReleaseAt: String? = null,

这些字段并非 GitHub API 直接返回,而是在数据层通过「查询 release + 查询本地安装记录 + 收藏表」加工而来(详见第三节),它们支撑了仓库列表页上的「可安装 / 已安装 / 最新版本」等商店化信息展示。

2.4 过滤与排序类型

原文档提到 RepoFilterType (All/Sources/Forks/…) 与 RepoSortType (Stars/Name/Updated/…)。以当前仓库源码为准,实际枚举定义在 RepoFilterType.kt 与 RepoSortType.kt:

enum class RepoFilterType {
    WITH_RELEASES,   // 仅显示有 Release 的仓库
    WITH_INSTALLABLE, // 仅显示包含可安装资产的仓库
    INSTALLED,        // 仅显示本机已安装的仓库
    FAVORITES,        // 仅显示已收藏的仓库
}

enum class RepoSortType {
    UPDATED,  // 按最近更新排序(默认)
    STARS,    // 按 Star 数排序
    NAME,     // 按名称排序
}

可以看到,实际实现的过滤维度是围绕商店场景设计的(有 Release、可安装、已安装、收藏),而非文档中提到的 All/Sources/Forks——后者可以理解为早期设计意图,当前实现更贴合「应用商店」的产品定位。

2.5 贡献日历模型

ContributionCalendar.kt 与 ContributionDay.kt 组成了贡献图数据:

data class ContributionCalendar(
    val totalLastYear: Int,     // 过去一年总贡献数
    val days: List<ContributionDay>,
)

data class ContributionDay(
    val date: String,  // 日期(如 "2025-09-30")
    val count: Int,    // 当日贡献数
    val level: Int,    // 贡献强度等级(用于绘制色块深浅)
)

level 字段驱动 UI 侧贡献色块的深浅渲染,对应组件见 presentation/components/ContributionCalendar.kt。

三、数据层(data):后端优先、GitHub 兜底的双通道实现

数据层实现位于 feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt,其构造函数注入了 5 个依赖:

class DeveloperProfileRepositoryImpl(
    private val clientProvider: GitHubClientProvider,   // 直连 GitHub 的 HttpClient
    private val backendApiClient: BackendApiClient,     // 项目自建后端 API 客户端
    private val platform: Platform,                    // 当前平台(Android/Windows/macOS/Linux)
    private val installedAppsDao: InstalledAppDao,      // 本地安装记录 DAO
    private val favouritesRepository: FavouritesRepository, // 收藏仓库仓库
    private val logger: KomiStoreLogger,
) : DeveloperProfileRepository

依赖注入装配见 feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/di/SharedModule.kt。

3.1 开发者资料:先走后端,失败再直连 GitHub

getDeveloperProfile 的调用流程(见 DeveloperProfileRepositoryImpl.kt#L51-L81):

  1. 先调用 backendApiClient.getUser(username);
  2. 后端成功 → 直接映射为领域模型返回;
  3. 后端失败 → 通过 shouldFallbackToGithubOrRethrow(error) 判断是否允许降级到直连 GitHub;若不允许则直接返回 Result.failure;
  4. 降级路径请求 GET /users/$username,非 2xx 状态码返回 Result.failure(Exception(...));
  5. 解析 GitHubUserResponse 后映射为 DeveloperProfile。

异常处理上有意「放行」两类异常:RateLimitException(交给上层弹限流提示)和 CancellationException(协程取消必须向上传播),其余异常记录日志并封装为 Result.failure。

DTO 定义在 GitHubUserResponse.kt,全部字段通过 @SerialName 与 GitHub API 的 snake_case 字段对齐(如 avatar_url、public_repos、twitter_username、html_url 等)。

3.2 仓库列表:分页拉全 + 逐仓库增强

getDeveloperRepositories 的流程更复杂,体现了商店场景的独特需求:

  1. 分页拉取全部 owner 仓库:每页 100 个(perPage = 100),同样走「后端优先、GitHub 兜底」双通道;后端请求参数为 sort="updated"、type="owner";降级时直连 GET /users/$username/repos?per_page=100&page=N&type=owner&sort=updated&direction=desc;
  2. 过滤:pageRepos.filter { !it.archived && !it.fork }——归档仓库与 fork 仓库直接剔除,保证列表质量;
  3. 并发增强:对每个仓库并行执行 processRepository,使用 Semaphore(20) 限制并发数为 20,避免一次并发请求过多;
  4. 增强逻辑(processRepository,见 DeveloperProfileRepositoryImpl.kt#L198-L218):
    • installedAppsDao.getAppsByRepoId(repo.id) 查询本地安装记录 → isInstalled = installedApps.any { !it.isPendingInstall }(待安装任务不计入已安装);
    • favouritesRepository.getAllFavorites() 获取全部收藏 ID 集合 → 标记 isFavorite;
    • checkReleaseInfo(owner, repoName) 查询 release → 填充 hasReleases、hasInstallableAssets、latestVersion、latestReleaseAt。

3.3 平台感知的可安装资产判定

checkReleaseInfo 中有一个非常关键的实现细节:「可安装」的判定是平台感知的(见 DeveloperProfileRepositoryImpl.kt#L242-L250):

val hasInstallable = stableRelease.assets.any { asset ->
    val name = asset.name.lowercase()
    when (platform) {
        Platform.ANDROID -> name.endsWith(".apk")
        Platform.WINDOWS -> name.endsWith(".msi") || name.endsWith(".exe")
        Platform.MACOS   -> name.endsWith(".dmg") || name.endsWith(".pkg")
        Platform.LINUX   -> name.endsWith(".appimage") || name.endsWith(".deb") ||
                            name.endsWith(".rpm") || name.endsWith(".pkg.tar.zst")
    }
}

即:同一个仓库,在 Android 端看是否有 .apk 资产,在 Windows 端看 .msi/.exe,在 macOS 端看 .dmg/.pkg,在 Linux 端看 .appimage/.deb/.rpm/.pkg.tar.zst。同时只取非 draft、非 prerelease 的稳定版(stableRelease = releases.firstOrNull { it.draft != true && it.prerelease != true }),并将 tagName 作为最新版本号、publishedAt 作为最新发布时间。release 查询同样遵循「后端优先、GitHub 兜底」模式,后端失败时直连 GET /repos/$owner/$repoName/releases?per_page=10。

这套逻辑也解释了为什么 DeveloperRepository 模型需要 hasReleases(有 release 但无可安装资产)与 hasInstallableAssets(有可安装资产)两个独立布尔字段——它们是 WITH_RELEASES 与 WITH_INSTALLABLE 两类过滤的判定依据。

四、表现层(presentation):并行加载与客户端过滤排序

4.1 ViewModel 的数据加载编排

DeveloperProfileViewModel.kt 通过 MutableStateFlow<DeveloperProfileState> 暴露 UI 状态,首次订阅时(onStart + hasLoadedInitialData 防重复加载)触发 loadDeveloperData()。其中明确体现了文档 Notes 中「Profile + repos fetched in parallel」的设计:

  • 同一个 viewModelScope.launch 中先发起 getDeveloperProfile(username);
  • 接着不等待 profile 完成便调用 getDeveloperRepositories(username)(两段代码顺序执行,但仓库请求与资料请求相互独立,本质上是并发发起的两个挂起点);
  • profile 成功后就更新 profile,并将 isLoadingContributions = !profile.isOrganization——非组织账号继续加载贡献日历 loadContributions();
  • 仓库成功后再执行 applyFiltersAndSort() 生成展示列表。

加载期间 isLoading / isLoadingRepos / isLoadingContributions 三个状态位分别控制整体加载态、仓库区加载态与贡献图加载态,做到分区域的精细 UI 反馈。

4.2 客户端过滤、排序与搜索

applyFiltersAndSort()(见 DeveloperProfileViewModel.kt#L143-L198)在 Dispatchers.Default 中执行,验证了文档「Client-side filter/sort」的约定:

  • 搜索:searchQuery 非空时,对 repo.name 与 repo.description 做不区分大小写的 contains 匹配;
  • 过滤:按 currentFilter(WITH_RELEASES / WITH_INSTALLABLE / INSTALLED / FAVORITES)逐一 filter;
  • 排序:按 currentSort 排序——UPDATED 按 updatedAt 降序、STARS 按 stargazersCount 降序、NAME 按名称升序。

所有过滤与排序都在本地内存中完成,不触发新的网络请求,切换过滤/排序条件时响应迅速。

4.3 Action 驱动的交互

UI 事件通过 DeveloperProfileAction 统一派发(对应文件 DeveloperProfileAction.kt),核心分支包括:

  • OnFilterChange / OnSortChange / OnSearchQueryChange:更新对应状态后立即 applyFiltersAndSort();
  • OnToggleFavorite:构造 FavoriteRepo(包含 repoId、repoOwner、repoOwnerAvatarUrl、latestVersion 等),调用 favouritesRepository.toggleFavorite(),并同步更新内存中的 isFavorite 标志后再过滤排序;
  • OnRetry:重新触发 loadDeveloperData();
  • OnDismissError:清空 errorMessage。

顶层 UI 组装在 DeveloperProfileRoot.kt,组件拆分为 ProfileInfoCard.kt(资料头卡)、DeveloperRepoItem.kt(仓库条目)、FilterSortControls.kt(过滤/排序控件)与 ContributionCalendar.kt(贡献图)四部分。

五、导航集成:从任意卡片进入开发者主页

原文档明确指出导航入口为 GithubStoreGraph.DeveloperProfileScreen(username: String)。在导航图定义 GithubStoreGraph.kt 中,它以 @Serializable data class 形式注册:

@Serializable
data class DeveloperProfileScreen(
    val username: String,
) : GithubStoreGraph

该路由在 AppNavigation.kt 中通过 composable<GithubStoreGraph.DeveloperProfileScreen> 注册,并由 NavigationUtils 的 toRoute<GithubStoreGraph.DeveloperProfileScreen>() 统一转换深链路由(见 NavigationUtils.kt)。

从源码检索来看,DeveloperProfileScreen(username = ...) 的调用点分布在:

这正好印证了文档 Notes 中「Reached from cards throughout app (home/search/details/favourites/starred)」——即无论用户从首页推荐、搜索结果、应用详情、收藏列表还是星标列表中的哪一张仓库卡片点击,最终都会携带 username 路由到这里。

六、设计要点小结

设计决策 实现位置 说明
接口返回 Result<T> 而非抛异常 DeveloperProfileRepository.kt 与项目全局错误处理约定一致;RateLimitException 与 CancellationException 特殊放行
后端优先、GitHub 兜底 DeveloperProfileRepositoryImpl.kt#L51-L81 降低对 GitHub 直连的依赖,后端不可用时经 shouldFallbackToGithubOrRethrow 降级
Profile 与 Repos 并行加载 DeveloperProfileViewModel.kt#L59-L85 资料与仓库两个请求相互独立、并发发起
过滤/排序/搜索全部客户端完成 DeveloperProfileViewModel.kt#L143-L198 无额外网络开销,切换条件即时响应
平台感知的可安装资产判定 DeveloperProfileRepositoryImpl.kt#L242-L250 同一仓库在不同平台显示不同的「可安装」结果
组织账号跳过贡献图 DeveloperProfileViewModel.kt#L67-L72 基于 DeveloperProfile.isOrganization 派生属性
统一路由 DeveloperProfileScreen(username) GithubStoreGraph.kt#L30-L33 home / search / details / favourites / starred 多入口共用

对于希望继续深入该模块的读者,建议按以下顺序阅读源码:先看 CLAUDE.md 把握整体结构,再读 domain 层的接口与模型(DeveloperProfileRepository.kt、DeveloperProfile.kt、DeveloperRepository.kt),随后研读数据层实现 DeveloperProfileRepositoryImpl.kt 与其 DTO/mappers,最后对照表现层的 DeveloperProfileViewModel.kt 理解状态流转与客户端过滤逻辑。

登录后查看全文
komi-store