简介:基于Android的新闻推荐系统完整源码包,面向移动开发初学者、毕业设计选题者以及希望了解新闻类App整体架构的程序员。项目采用OkHttp与Gson实现网络请求和JSON解析,配合Glide处理图片加载,覆盖新闻列表、下拉刷新、加载更多、详情页以及滑动返回等核心功能,能够直接导入Android Studio运行,也可作为二次开发或课程设计的基础框架。压缩包共88个文件,主要包括34个Java源文件、30个XML布局及资源配置、10张PNG图片、Gradle构建脚本和Android Support库,整体体积仅645KB,结构精简便于快速检索与阅读。目前已有56人在线学习下载,适合需要一套轻量级新闻推荐客户端参考实现的开发者参考借鉴。
1. 为什么“基于Android的新闻推荐系统”不是个普通App
很多人在 Android Studio 里打开一个新闻类工程,第一反应是看列表、看 WebView。但“基于Android的新闻推荐系统”这个源码包的真正价值,不在新闻展示,而在“推荐”二字。它解决的是:手机端怎么在离线也有推荐结果、点击行为不丢、服务端接口抖动时仍能出列表的问题。适合两类人:一是想弄懂推荐系统端上落地的客户端工程师,二是需要快速二次开发一个带推荐逻辑的新闻 App 的团队。反直觉的是,端上做推荐并不意味着服务端无用,而是把冷热路径拆开,让本地缓存和实时行为先跑起来。
2. 源码包解压与 Android Studio 导入:先搞清楚 zip 里装了什么
拿到一个后缀为 .zip 的 Android 源码包,最忌讳的是双击后直接拖拽到工程目录。因为 zip 在压缩时通常保留了内部目录结构,而 Android 工程对路径、文件名大小写、gradle wrapper 的相对位置极其敏感。我一般先建一个干净目录,比如D:/news_reco,把 zip 完整解压后,再根据settings.gradle确认工程根目录。
2.1 解压 zip 的两种正确姿势,以及一个常见误用
Windows 上右键“全部解压”能处理大部分情况,但它对源路径中的中文目录名兼容一般。更可控的做法是命令行:
unzip -q NewsRecommend.zip -d D:/news_reco参数说明:-q静默解压,避免刷屏;-d指定目标目录。如果目标是 Android Studio 直接识别,解压后检查根目录是否有settings.gradle和build.gradle,有才说明这是一个完整的 Gradle 工程。没有的话,就只是一个库或模块,需要自己创建工程再引入。
常见误用是直接在 Android Studio 里用 File > Open 选中 zip 文件本身,AS 会把它当普通压缩包,不会自动解压。另一个误用是从微信或钉钉下载的 zip,文件名里带content://前缀,尤其在 FileProvider 接管下载目录后,文件实际路径不是external_path,而是私有缓存目录。此时先在“下载”目录里找到真实文件再解压,不要直接复制 content URI 路径去读。
2.2 Android Studio 导入前的 SDK 与 Gradle 版本匹配
打开源码包里的build.gradle时,先不要急着 sync。我习惯先看三个值:compileSdk、minSdk和targetSdk。在较新的 Android Studio(如 2023.3.1 之后的版本)里,这些值已经迁移到build.gradle.kts中,用compileSdk = 34的写法。常见的对应关系如下。
| compileSdk | 对应 Android 版本 | 推荐 Gradle 版本 | 推荐 JDK 版本 |
|---|---|---|---|
| 31 | Android 12 | 7.0+ | 11 |
| 33 | Android 13 | 7.4+ | 11 |
| 34 | Android 14 | 8.0+ | 17 |
注意,这不代表高版本 Gradle 一定能编译旧源码。如果源码里gradle/wrapper/gradle-wrapper.properties写的是gradle-6.8,而电脑上装的 AS 是 2024 版,sync 大概率会提示“Minimum supported Gradle version is 8.0”。这时有两种选择:升级 wrapper 里的 distributionUrl,或下载一个旧版 AS。我一般优先升级 wrapper,因为第三方库和 SDK 都是向前兼容的,反而旧版 AS 较难跑到 Android 14 的模拟器。
2.3 第一次构建失败时优先检查的三个文件
拿到源码后第一次构建,失败点通常不在业务代码,而在三个配置文件。
gradle-wrapper.properties:检查distributionUrl里的 Gradle 版本是否能正常下载。下载慢时,可以手动把 zip 放到~/.gradle/wrapper/dists下对应目录,比反复 sync 快。local.properties:确认sdk.dir指向本机 Android SDK 的实际路径。比如sdk.dir=C\:\\Users\\admin\\AppData\\Local\\Android\\Sdk。AS 会自动生成,但源码包里如果夹带了原作者机器的绝对路径,sync 会直接失败。- 根
build.gradle里的dependencies:如果声明了不在默认仓库的私有库,比如maven { url '...' }指向内网,你就得先移除或替换。
2.3.1 一个小脚本快速检查 zip 完整性
解压后可以写一行命令确认工程可编译的最少文件存在:
ls -la settings.gradle build.gradle gradlew gradle/wrapper/gradle-wrapper.properties这四条文件同时存在,说明源码包大概率是完好的 Android 工程。缺少gradlew时,在 AS 里也能生成,但需要联网下载 Gradle;如果内网环境,建议先把整个gradle目录保留好。这个脚本能帮你把“报错”和“源码本身不完整”分开。
3. 新闻推荐系统的三层架构与 Android 端边界
一个基于 Android 的新闻推荐系统,并不是把推荐算法全堆在手机里,也不是只做服务端的展示壳。常见做法是三层:数据层负责从接口或本地缓存拿原始新闻,推荐层负责算相似度和召回排序,展示层负责把结果渲染到 RecyclerView 和 Banner。端上推荐最大的好处是,服务端故障时,本地缓存加行为日志仍能让用户刷到内容。
3.1 数据层:用 Retrofit 拉取新闻流并写入 SQLite
新闻数据一般用 JSON 数组返回,字段至少要有 newsId、title、category、content 和 publishTime。在 Android 工程里,我习惯用 Repository 模式,上层 ViewModel 不直接碰网络。下面这段是去掉第三方库具体版本后的核心结构:
public class NewsRepository { private final NewsApi api; private final NewsDao dao; public NewsRepository(NewsApi api, NewsDao dao) { this.api = api; this.dao = dao; } public List<News> fetch(int page, int pageSize) { List<News> remote = api.getNews(page, pageSize).body(); if (remote != null) { dao.insertAll(remote); // 写本地缓存 } return remote == null ? dao.loadRecent(pageSize) : remote; } }参数说明:page表示翻页页码,建议从 1 开始,服务端不要用 0 偏移,否则数据库主键容易撞;pageSize控制单页新闻条数,新闻列表一般取 20,Banner 位取 5。代码里dao.loadRecent(pageSize)是冷启动保底逻辑——网络失败时直接返回 SQLite 中最近缓存的数据。这个逻辑放在 Repository,而不是放在 UI 层,是为了让推荐层也能复用到同一份缓存。
3.2 推荐层:基于内容的 TF-IDF 相似度在端上怎么算
端上要跑推荐,不能依赖重型框架。最稳的方案是离线把新闻标题和分类存进 SQLite,在内存里算 TF-IDF 向量,再算余弦相似度。TF-IDF 的核心是把“高频但无区分度的词”降权,比如“今天”“新闻”。下面是一个基于 HashMap 的纯 Java 实现:
public double cosine(Map<String, Double> a, Map<String, Double> b) { double dot = 0.0, normA = 0.0, normB = 0.0; for (Map.Entry<String, Double> e : a.entrySet()) { Double bVal = b.get(e.getKey()); if (bVal != null) dot += e.getValue() * bVal; normA += e.getValue() * e.getValue(); } for (double v : b.values()) normB += v * v; return dot / (Math.sqrt(normA) * Math.sqrt(normB) + 1e-8); }1e-8是防止除零的平滑系数,两个完全一样的文档相似度为 1,完全无关接近 0。真正生产环境还会做 IDF 常数的平滑:idf = 1 + log(N / (df + 1)),这里的+1避免分母为 0。端上因为新闻总量固定,可以把 IDF 预先算好存一个 Map,不用每次请求都遍历全库。
3.2.1 基于内容与协同过滤在 Android 端的取舍
| 方案 | 端上可行性 | 冷启动表现 | 计算复杂度 |
|---|---|---|---|
| 基于内容 | 最好,用 SQLite 全局统计 | 差,新栏目没用户行为 | 低,只需算当前文章向量 |
| 协同过滤 | 中,行为表需要定期清理 | 好,热门新闻可直接填充 | 高,用户-物品矩阵随规模膨胀 |
| 混合推荐 | 推荐,端上用规则加权 | 最好,热门兜底 | 中,需要配置权重 |
协同过滤在 Android 端最大的问题是用户行为矩阵容易膨胀。如果 10 万个用户,每个用户 1000 条点击,光矩阵在内存里就几百 MB。所以源码包里如果看到的只有基于内容推荐,并不是能力缺失,而是工程取舍。要做协同过滤,我一般只保留最近 7 天的行为,并限制每用户最多 200 条,超出后按时间戳裁剪。
3.3 展示层:Android 进度条与 RecyclerView 的异步刷新
新闻推荐列表的体验瓶颈在异步加载。网络请求回来后不能直接把数据塞给 RecyclerView,否则会触发布局和线程问题。标准做法是 ViewModel 暴露 LiveData,Activity 观察后再更新适配器。加载更多时,用进度条兜底:
binding.progressBar.setVisibility(View.VISIBLE); newsViewModel.fetchNextPage().observe(this, list -> { binding.progressBar.setVisibility(View.GONE); adapter.submitList(list); });参数说明:progressBar使用 Android 原生的ProgressBar,建议在onStop时置为 GONE,否则页面切后台后通知栏仍然转圈。配合CoordinatorLayout+AppBarLayout时,进度条最好放在内容列表的底部,而不是悬浮在 Banner 上,避免遮挡新闻标题。如果源码包有 Banner 轮播,记得在onResume启动轮播、onPause停止,否则 Activity 不可见时仍在切换,会浪费 CPU。
4. 推荐算法核心参数与调优:别只改按钮颜色
拿到源码,第一件事不是换包名,而是把推荐开关和参数列出来。很多工程把“推荐”写死成一个 List 返回,改参数得改代码重新编译。稍微像样一点的工程,会把参数收敛到一个RecoConfig里,让运营和测试能动态调整。
4.1 召回参数:TopN 与时间衰减因子
召回阶段决定“从 5000 条新闻里挑出哪 50 条”。两个参数最关键:
topN:最终返回给用户的新闻条数。新闻列表首屏建议 10,第二屏起每次 20。设太大会导致用户刷几屏都是相似内容,太小又容易重复。timeDecay:时间衰减因子,用于计算新闻新鲜度得分。常用公式score = sim_score * exp(-alpha * age_hours)。alpha取 0.05 时,24 小时前的新闻基本没有竞争力;取 0.01 时则更看重相似度。
| alpha 取值 | 24小时后的衰减系数 | 适合场景 |
|---|---|---|
| 0.01 | 0.787 | 深度长文、杂志类 |
| 0.05 | 0.301 | 常规新闻流 |
| 0.1 | 0.091 | 突发新闻、追热点 |
端上因为新闻量有限,建议把alpha放在 Remote Config 里,服务端切值,客户端拉取后缓存。如果在源码里改死,每次运营要拿用户测试都得重新打包,违背推荐系统快速迭代的初衷。
4.2 相似度计算的几个必调参数
无论用 TF-IDF 还是 BM25,有四个参数值得花时间。
特征维度。新闻标题分词后最多取 20 个词,正文摘要取 100 个词。多了端上计算慢,少了区分度不够。注意标题和正文要分开统计 IDF,不能混在一起,否则标题的高权重词会被正文淹没。
平滑系数。余弦相似度分母里1e-8调大,比如1e-6,会让所有非零向量的相似度略微降低,对近乎相同的文章抑制作用更强。但调太大会把高相似文章也压下来,建议只在 A/B 验证时改。
阈值。基于内容的推荐通常要设一个sim_threshold = 0.35,低于这个值的内容补一个“热门新闻”兜底。阈值调高,推荐列表就专,但可能会缩到几篇老文章;调低,就会混入弱相关内容。
行为权重。如果系统同时记录点击、收藏、分享,那么推荐排序可以用加权公式:
final_score = 0.6 * content_score + 0.3 * click_score + 0.1 * fresh_score
代码上就是把这个权重表做成一个可解析的配置文件:
{ "content_weight": 0.6, "click_weight": 0.3, "fresh_weight": 0.1, "sim_threshold": 0.35, "top_n": 20 }注意,click_score不要直接用点击次数,需要用log(1 + click_count)压缩长尾,避免老新闻靠累计点击霸榜。源码包如果已经实现这个配置,调优时可以只改 JSON 后重新加载;如果没有,建议自己加一个RecoConfig单例。
4.3 冷启动问题的三个默认策略
新用户没行为,需要冷启动。我见过最省事的策略是:
- 按分类平均分配:比如默认选中“科技”“体育”“财经”,每个分类召回 N 条,再按时间倒序。
- 热门新闻加权:全局点击率最高的 20 条新闻,直接插到推荐流第 1、5、10 位。
- 地域化兜底:如果新闻带城市字段,可以把本城市新闻排前。
冷启动策略代码通常写在RecommendEngine的入口处:
if (userHistory.isEmpty()) { return hotNewsWithCategoryBalanced(10, 20); }这里hotNewsWithCategoryBalanced要同时保证两个约束:总数等于topN,且每个分类至少有 1 篇。实现时可以先用热门池填充,再按分类比例采样。直接new Random()随机选分类是错的,会导致某次全是科技,某次全是娱乐。
5. 验证推荐效果与远程调参:把源码盘活
推荐的验证,不要只靠眼睛刷屏。源码包能跑起来后,第一件事是把点击行为落库。我通常直接在 SQLite 里建一张reco_log表,字段包括news_id、position、expose_time、click_time、strategy。每次 RecyclerView 显示第几个 item 就记一次曝光,点击时更新 click_time。
然后可以写一个简单的离线评估脚本(甚至可以放在 instrumentation test 里):
SELECT COUNT(DISTINCT news_id) AS exposed, SUM(CASE WHEN click_time IS NOT NULL THEN 1 ELSE 0 END) AS clicked, ROUND(SUM(CASE WHEN click_time IS NOT NULL THEN 1 ELSE 0 END) * 1.0 / COUNT(DISTINCT news_id), 4) AS ctr FROM reco_log WHERE strategy = 'content_based';这段 SQL 直接告诉你基于内容的推荐点击率是多少。只看总点击数不看曝光数没有意义,一定要算 CTR(点击/曝光)。CTR 低于 1% 时,先怀疑sim_threshold是不是太高,导致推荐的东西太窄。
再进一步,可以把 JSON 配置改用本地 SharedPreferences 缓存,然后做远程下发。常见做法是接 Firebase Remote Config,但国内环境也可以用自研配置接口,返回一个Map<String, Double>。客户端启动时异步拉取,拉到之后写入内存,不用重新打包。这个能力的价值在于:你可以在后台把一个分类的权重从 0.5 改成 0.3,然后观察当天 CTR 变化,这是一个最小可行的在线实验闭环。
最后一个技巧是给 RecyclerView 的onScroll加一个节流阀。新闻流本身就会频繁触发曝光上报,如果每次滑动一像素都写数据库,SQLite 的锁竞争会卡掉 UI。我一般用 300ms 的 Handler 节流,累积 offset 后只记录可见的第一个和最后一个 item 的位置。点击是稀疏事件,不需要节流;曝光事件必须节流,否则测试阶段看到电量哗哗掉就是这里漏了。
本文还有配套的精品资源,点击获取