ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android新闻推荐系统源码解析:从OkHttp到推荐算法的完整工程实践

Android新闻推荐系统源码解析:从OkHttp到推荐算法的完整工程实践 简介一套基于Android平台的新闻推荐系统源码面向移动开发学习者、课程设计与毕业设计场景聚焦新闻类应用的列表展示、详情阅读与交互体验。项目包含新闻列表展示、下拉刷新、加载更多、新闻详情、关于页面与滑动返回等模块使用OkHttp完成网络请求Gson解析接口数据Glide异步加载图片代码功能完整便于理解Android网络层、数据层与UI层的协作方式。资源共88个文件主要由34个Java业务代码、30个XML界面与配置、10张PNG图片资源、4个Gradle构建脚本和2个Jar依赖包组成压缩包大小为645KB包含主工程、构建配置、资源目录及说明文档目录结构紧凑可直接导入开发环境运行。目前已有56人浏览学习适合需要获取可运行新闻推荐项目或希望对照学习OkHttp、Gson、Glide组合用法的开发者。1. 一个能装进手机里的新闻推荐系统到底在推荐什么做信息流客户端的人都知道推荐系统听起来是服务端的事——用户画像、协同过滤、排序模型都在后端。但真正决定用户“会不会往下划”的往往是最靠近用户的那一层新闻列表怎么加载、图片会不会卡、详情页返回顺不顺、推荐位放哪个位置。这套基于Android的新闻推荐系统源码就是一个把服务端推荐逻辑“降级”到客户端落地的完整示例用OkHttp拉取新闻数据、Gson解析JSON、Glide异步加载图片、SwipeBack实现滑动返回同时内置了一套基于用户行为标签的轻量推荐策略。适合两类人看一是想把客户端列表页做扎实的Android开发二是想理解推荐系统的数据流在前端怎么闭环的产品或服务端同学。拆这份源码比看一堆架构图更能说明问题。2. 项目结构与数据链路Gradle配置、OkHttp封装与Gson解析2.1 源码目录里每个模块分别负责什么拿到压缩包解压之后先别急着用Android Studio打开把目录结构扫一遍。根目录下有gradle.properties、gradle/wrapper/gradle-wrapper.properties、build.gradle、settings.gradle、gradlew这套是标准的Gradle构建脚手架。gradle-wrapper.properties里锁定了Gradle版本团队协作时保证大家构建环境一致你本机没装对应版本也会自动下载。settings.gradle里通常会include:app和:swipeback两个模块——其中swipeback就是那个提供“滑动返回上一页”能力的开源库独立成Module而不是打成jar说明作者要针对这个页面导航场景做定制。app/src/main/java下是业务代码按功能拆包联网请求、模型对象、界面Activity和Adapter。libs/android-support-v4.jar是历史遗留的Support库现在新项目大多迁移到AndroidX了但这个项目用的Activity继承体系还是基于support.v4所以保留了jar包依赖。你如果用新版Android Studio导入建议先打开build.gradle核对compileSdkVersion是否匹配不然会有依赖冲突。2.2 OkHttp网络层超时、拦截器与异步回调新闻数据的获取统一走了OkHttp。看主代码里的封装典型写法是在一个工具类里初始化OkHttpClient单例再对外暴露get/post方法。public class HttpUtil { private static OkHttpClient client; public static OkHttpClient getClient() { if (client null) { synchronized (HttpUtil.class) { if (client null) { client new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS) .retryOnConnectionFailure(true) .addInterceptor(new HttpLoggingInterceptor() .setLevel(HttpLoggingInterceptor.Level.BODY)) .build(); } } } return client; } public static void get(String url, Callback callback) { Request request new Request.Builder() .url(url) .header(User-Agent, NewsClient/1.0 (Android)) .header(Accept, application/json) .get() .build(); getClient().newCall(request).enqueue(callback); } }几个参数的取舍要说明一下connectTimeout设为10秒覆盖了弱网下TCP握手可能出现的多次超时重传readTimeout比连接超时长因为新闻接口返回的JSON体量大服务端做推荐排序计算本身也有耗时日志拦截器只在debug环境开线上要关掉否则每屏请求都会把响应体打到Logcat里既费电又容易把敏感数据带出来。enqueue走的是OkHttp内部线程池回调在子线程执行所以拿到Response后要手动切回主线程才能更新UI。这套写法比execute同步调用好在不会阻塞主线程但代价是回调里要自己对异常分支做处理常见问题是在onFailure里忘了提示用户导致列表干等无反馈。2.3 Gson把JSON字段映射成Java对象解析层用的是Gson。新闻列表和新闻详情是两个接口列表页返回缩略信息详情页返回正文。典型映射关系如下。public class NewsItem { SerializedName(news_id) private String id; SerializedName(title) private String title; SerializedName(source) private String source; SerializedName(publish_time) private long publishTime; SerializedName(image_url) private String imageUrl; SerializedName(category) private String category; // getter / setter 省略 }用SerializedName把后端下划线字段名转成Java的驼峰命名是Gson处理接口字段不一致的标准做法。这里有个容易踩的坑后端返回的publish_time是Unix时间戳单位秒而Java的System.currentTimeMillis()是毫秒做时间显示时如果不乘以1000列表上所有时间都会显示成1970年。另一个点是image_url可能为空——首页运营位常出现纯文本新闻所以Glide加载前一定要判空否则会走error占位图。字段映射表如下方便你拿着真实接口自己对照调参。JSON字段Java字段类型说明news_ididString新闻唯一ID详情页请求参数titletitleString标题列表项主文本sourcesourceString来源媒体列表项副文本publish_timepublishTimelong时间戳秒级展示需乘1000image_urlimageUrlString缩略图地址可能为nullcategorycategoryString新闻分类推荐算法的输入特征contentcontentString正文仅详情接口返回Gson解析多态时也有坑。推荐接口有时会在Item里嵌套一个extra对象字段名不固定这时用JsonObject二次解析比硬建泛型要稳得多。反序列化ListNewsItem用TypeToken避免类型擦除导致的ClassCastException。3. 列表到详情RecyclerView、下拉刷新与SwipeBack滑动返回的协作3.1 下拉刷新与加载更多的正确触发时机主界面的列表承载了推荐流的核心展示实现用SwipeRefreshLayout包住RecyclerView。下拉刷新由SwipeRefreshLayout自带上拉加载更多则需要监听滚动位置手动触发。swipeRefreshLayout.setOnRefreshListener(() - { currentPage 0; loadNews(currentPage, true); }); recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { Override public void onScrolled(RecyclerView recyclerView, int dx, int dy) { LinearLayoutManager lm (LinearLayoutManager) recyclerView.getLayoutManager(); int lastVisible lm.findLastVisibleItemPosition(); int totalCount lm.getItemCount(); if (totalCount - lastVisible 3 !isLoading) { isLoading true; loadNews(currentPage, false); } } });关键点在totalCount - lastVisible 3这个阈值。如果把判断写成lastVisible totalCount - 1用户滑到最底部才开始加载遇到慢网时屏幕上会有明显的白屏等待期。提前3条就发起请求等用户滑到底时数据已经就位体验上“无缝加载”。isLoading这个boolean必须加否则滚动监听会连续触发多次请求导致同一页数据重复加载、列表出现重复项。代码里每页返回多少条要与服务端约定成固定值常见是每页1020条下拉刷新时currentPage0上拉时自增。接口返回后要做去重。Refresh时直接清空列表再add全部LoadMore时要根据newsId去重因为刷新和加载可能拿到同一批数据。这里我一般用HashSet记住已加载的newsId插入前做一次过滤。3.2 详情页的数据传递传ID而不是传对象点击列表项跳转到详情页Intent传参的原则是“传ID不传对象”。把整个NewsItem用Parcelable序列化塞进Intent看似省了一次网络请求但隐患有两个一是Parcelable在进程间传递时有大小限制文本型字段还好如果对象里塞了base64缩略图跨进程直接TransactionTooLargeException二是详情页的正文内容本来就该由详情接口返回传对象会绕过数据校验服务端改了正文客户端还在显示旧数据。正确做法是Intent只传newsId详情页onCreate里拿ID发起新的OkHttp请求。详情页多数用ScrollView嵌套TextView渲染正文配一个顶层ImageView当题图。正文HTML转义是另一个常见坑如果服务端返回的是富文本标签直接用TextView显示会看到一串标签源码至少要做一个基础的stripHtml处理把p、img过滤或转换为换行与图片加载。3.3 SwipeBack滑动返回的接入与手势冲突处理滑动返回不是Google原生组件属于第三方交互增强。这个项目的swipeback模块实现思路是对Activity的decorView做手势监听手指水平滑动时实时平移contentView松手时根据位移距离和速度决定finish当前页还是回弹。接入方式如下。public class DetailActivity extends SwipeBackActivity { Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // 必须在super.onCreate之后立刻设置否则滑动动画不生效 setSwipeBackEnable(true); // 只允许右边缘触发避免和列表页横向滑动冲突 setEdgeTrackingEnabled(SwipeBackLayout.EDGE_LEFT); } }setSwipeBackEnable(true)是关闭这个页面的滑动返回能力比如首屏主界面就不该能滑走。setEdgeTrackingEnabled(SwipeBackLayout.EDGE_LEFT)限定从屏幕左边缘开始滑才触发因为RecyclerView内部有横向滑动逻辑比如轮播图全屏手势会互相抢事件。集成后要专门测两类场景一是在详情页的正文ScrollView里上下滑不能误触返回二是快速左右横滑时Activity不能出现半透明残影。残影问题一般在onDestroy里把SwipeBackLayout的监听置空并在finish后调用overridePendingTransition(0, 0)清除默认切换动画让滑动返回的过渡效果完全由该库接管。滑动返回的兼容性要单列关注开启系统手势导航的机型上从屏幕两侧向内滑本身就会触发系统返回第三方库再叠加一次会导致“一次手势页面被关了两次”的假象。所以我通常在BaseActivity里判断Build.VERSION.SDK_INT 29时自动关闭SwipeBack的Edge_Tracking把返回交给系统手势。4. Glide图片加载的缓存策略与列表流畅度优化4.1 为什么是Glide而不是Fresco或Picasso图片加载是新闻列表最影响性能的环节原项目选Glide是因为它在体积、功能、接入成本之间取得了平衡。对比维度GlidePicassoFresco包体积较小小大依赖So库磁盘缓存维度未解码原始数据 解码后资源仅原始数据三级缓存维度最细Activity生命周期感知自动跟踪自动跟踪需要容器包装GIF支持支持支持支持内存占用优化默认RGB_565内存减半默认ARGB_8888匿名共享内存异步解码上手复杂度低低高Fresco的内存控制其实比Glide更强它把图片放在native heap而不是java heap能够大幅降低OOM概率但代价是你要用SimpleDraweeView替换整个项目的ImageView改造成本极高。这个新闻推荐系统用的是通用适配器模式Glide的into(imageView)可以直接作用于现有布局是不改架构的前提下优先级最高的方案。4.2 关键API参数占位图、错误图与尺寸裁剪Glide调优的重点是把图片在解码阶段就压缩到View需要的尺寸。RequestOptions options new RequestOptions() .placeholder(R.drawable.img_placeholder) .error(R.drawable.img_error) .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) .override(720, 480) .centerCrop(); Glide.with(imageView.getContext()) .load(newsItem.getImageUrl()) .apply(options) .into(imageView);注意这几个可调参数。override(720, 480)是告诉Glide只要解码长宽为720x480的bitmap就够了如果原图是1920x1080解码内存直接降到原来的1/6这是防OOM最立竿见影的参数不要漏。centerCrop()会按照目标区域等比缩放并裁掉多余部分保证列表项图片完全填满ImageView——如果改成fitCenter()宽高比不一致的图会留白视觉上产生错位感。diskCacheStrategy.AUTOMATIC在Glide4.x中的行为是小图只缓存DATA大图缓存解码后的RESOURCE避免二次解码开销。加载时还要注意让图片尺寸适配不同分辨率的手机。列表缩略图用固定像素并不合理我一般先拿DisplayMetrics拿到屏幕宽度再按列数和图片宽高比计算override尺寸做到高清屏上不模糊、低端机上不爆内存。4.3 RecyclerView复用时的异步错位新闻列表快速滑动时ImageView被RecyclerView复用上一条的图片还在异步加载中就把位置让给了新item此时会短暂显示旧图或错图。Glide有内置into机制处理大部分情况但如果手动用了自定义Target或者占位动画就要在onBindViewHolder里主动清理。Glide.with(holder.itemView.getContext()) .load(newsItem.getImageUrl()) .apply(options) .into(holder.ivNewsImage);这段代码每次bind都重新执行loadGlide内部会为这个View生成唯一的RequestManager新request会cancel掉旧的。但要注意持有Context的时机——Activity或Fragment里用with(this)itemView里用with(holder.itemView.getContext())不能在非主线程调用with否则会抛出IllegalArgumentException。如果复用时出现旧图闪现检查有没有给RequestOptions设置dontAnimate()动画是错图感的主要来源。如果列表还需要支持大图点击预览Glide配合PhotoView这类缩放控件是一个轻量方案。加载原图前先用上面options生成的低清图做秒开背景加载完成后再替换看起来就像图“自己清晰了起来”。5. 推荐逻辑的工程化落地行为采集、冷启动兜底与效果验证5.1 用SharedPreferences维护一份轻量用户画像新闻推荐在客户端侧能做的本质是采集用户点击行为并映射成标签权重。推荐系统要解决的“千人千面”最小实现是维护一个category - score的计分表点击哪类新闻就给哪类加分同时做时间衰减。这个项目里没有用户登录体系所以画像只能存本地。public void recordClick(String category) { SharedPreferences sp getContext().getSharedPreferences(user_profile, Context.MODE_PRIVATE); long now System.currentTimeMillis(); long lastTime sp.getLong(last_time, now); int days (int) ((now - lastTime) / (24 * 60 * 60 * 1000)); SharedPreferences.Editor editor sp.edit(); for (String cate : sp.getStringSet(categories, new HashSet())) { int score sp.getInt(score_ cate, 0); // 每天衰减5%让长期不看的类别权重自然下降 editor.putInt(score_ cate, (int) (score * Math.pow(0.95, days))); } int newScore sp.getInt(score_ category, 0) 3; editor.putInt(score_ category, newScore); editor.putLong(last_time, now); editor.apply(); }衰减因子0.95是经验值。设得太高比如0.99用户三天前看的娱乐新闻权重仍然压过今天的科技新闻推荐列表会显得“迟钝”设得太低0.8短期兴趣冲刺过猛用户点一次财经突然刷出一屏财经反而造成信息茧房。按天为粒度做幂次衰减本质上模拟了遗忘曲线兼顾短期热度和长期偏好。5.2 冷启动没有行为数据时的兜底排序新安装用户本地没有画像此时如果直接跑推荐算法得到的是空列表。常见的兜底策略是“发布时间 全局热度”加权排序——新闻热度的经典公式是(阅读量 / 1000) (当前时间 - 发布时间)的负指数衰减越新越热。这里我给一个可落地的版本public float heatScore(NewsItem item, long now) { long ageHours (now - item.getPublishTime() * 1000) / 3600_000L; float readScore item.getReadCount() / 1000f; float timeDecay (float) Math.exp(-ageHours / 72.0); // 半衰期约3天 return readScore * timeDecay 1.2f / (ageHours 1); }ageHours / 72.0表示热度的半衰期是三天老新闻的曝光权重每三天折半避免旧闻一直霸榜。客户端拿不到阅读量时可以让服务端把热度直接算好随接口下发字段通常是hot_score或weight。等用户产生至少10次点击行为后再切换到个性化排序将画像中每个类别的score作为权重乘以该新闻的热度分得到最终排序分。切换阈值设在10次比较稳太少区分不出差异太多新用户已经流失了。5.3 如何验证推荐效果用SQLite做离线埋点验证推荐算法不是把代码跑通就完了。最轻量的验证方式是本地建一张SQLite日志表记录每一次曝光和点击的新闻ID、类别、时间戳离线跑指标。-- 曝光记录表 CREATE TABLE impression_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, news_id TEXT, category TEXT, timestamp INTEGER ); -- 点击记录表 CREATE TABLE click_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, news_id TEXT, category TEXT, timestamp INTEGER );计算点击率用一条SQL就能出结果SELECT i.category, COUNT(DISTINCT i.news_id) AS impressions, COUNT(DISTINCT c.news_id) AS clicks, ROUND(1.0 * COUNT(DISTINCT c.news_id) / COUNT(DISTINCT i.news_id), 4) AS ctr FROM impression_log i LEFT JOIN click_log c ON i.news_id c.news_id GROUP BY i.category ORDER BY ctr DESC;用adb把数据库拉出来跑这条SQL就能看到哪个类别曝光多但点击少——曝光高点击低基本可以定位推荐排序把用户不感兴趣的内容顶到了前边。判断推荐是否变好的核心指标有两个整体CTR点击除以曝光和人均点击次数。CTR提升说明推荐位的内容更贴合用户偏好人均点击次数的提升则说明用户在列表里的停留时间变长。埋点采集时要特别注意“有效曝光”的定义新闻项滚入屏幕超过200毫秒才算一次曝光用户蹭一下就滑走的不算。否则分母虚高CTR永远被稀释得很难看。控制在onBindViewHolder直接上报你会发现所有item只要滚过屏幕都记了曝光指标完全失真——用RecyclerView滚动监听加延时判断来打曝光点是验证推荐效果前最不该省的一个动作。本文还有配套的精品资源点击获取
返回列表