1. 项目概述爱读书到底要做成一款什么样的产品1.1 产品形态与适用人群前阵子一直在忙一个双端电子书项目把android基于安卓的电子书分享推荐系统爱读书APP 小程序这个标题变成了真实可跑的产品。一句话说明白这是一个同时具备Android原生App和微信小程序两个前端的电子书分享与推荐平台用户在两端都能上传、分享、阅读电子书后端根据阅读行为数据用协同过滤算法给每个用户算出个性化的书单。产品最终命名爱读书虽然名字听着朴素但整个闭环做得还算完整。这个项目适合两类人参考一类是正在做毕业设计或者个人作品集的同学需要一整套从前端到后端、再到推荐算法的可落地实现另一类是独立开发者想用最小成本跑通一个内容分享个性化推荐的产品模型。我自己在做的过程中把Android端、小程序端、Spring Boot后端和推荐算法全部走了一遍下面讲的每一个配置、每一段代码、每一个坑都是实际遇到过并解决的不是纸上谈兵。1.2 从标题里拆出来的三个核心关键词这个标题读起来有点绕但信息密度其实不低。我把它拆成三层来看。第一层是android/安卓加小程序这决定了产品是双前端形态。Android端承担重度使用场景下载到本地、离线阅读、阅读进度精确记录小程序端承担轻量社交场景在微信里收到好友分享的书单点开就能试读不用下载安装包。第二层是电子书分享这决定了产品不是一个单向卖书的书城而是用户既是读者也是供给者的UGC平台。用户可以分享自己整理的电子书资源、书单和读书笔记形成社区感。第三层是推荐系统这是整个项目的技术核心。用户看到什么书不是编辑人工排的而是根据行为数据算出来的每个用户首页的推荐流都不一样。把这三层叠加在一起你会发现这个项目的壁垒不在UI设计也不在某个单一功能而在于行为数据采集—推荐算法计算—内容个性化分发这条链路能不能完整跑通。2. 整体架构与方案选型2.1 双端前端的架构对比做双端产品最容易犯的错误是一套逻辑写两遍。当初我犹豫过用Flutter或者uni-app做跨端方案最后放弃了原因是爱读书这个项目里Android端和小程序端的核心诉求差异太大了强行跨端会让两端都做得不痛快。我前期拉了一张对比表这个决定后来被实践证明是对的。维度Android端微信小程序端产品定位重度阅读与管理本地书架轻量分享与社交传播即点即读本地存储文件级操作PDF/EPUB/TXT存应用目录受限只能用小程序缓存大文件走云端阅读进度支持离线阅读联网后后台同步依赖微信登录态实时在线同步分享能力FileProvider分享文件给其他App微信转发卡片、生成小程序码开发栈Kotlin Android StudioWXML WXSS JavaScript 微信开发者工具这两种技术栈差异决定了不能共用一套UI代码但后端接口是完全共用的。我采用RESTful API统一返回数据结构两端各自实现表现层。这么做还有个额外好处小程序端联调遇到诡异问题时可以打开Android端对比行为快速定位是接口问题还是端上实现问题。2.2 后端技术栈与推荐引擎的选型逻辑后端选型是Spring Boot MySQL Redis这是Java生态里最稳的组合。有人问为什么不把推荐服务用Python写我的判断是这个项目的推荐算法核心是一个ItemCF基于物品的协同过滤在书籍数量不超过几千本时用Java直接实现完全够用还省去了Python服务和Java服务之间的跨语言调用与部署成本。MySQL存业务主数据用户表、图书表、行为评分表、分享记录表。Redis做两层缓存第一层缓存热门榜单和推荐结果集合第二层临时存用户最近的操作行为用于后续实时召回。图书表和评分表在项目初期单库单表完全没压力但索引要提前设计好评分表必须建(user_id, book_id)联合索引推荐查询时频繁按用户聚合没有这个索引性能会差出数量级。双端调用同一套推荐接口但展示参数不同。Android端首页推荐流一次拉20条小程序端一次拉10条接口设计成GET /api/recommend/books?userIdxxxpage1size10返回结构里带一个reason字段用来展示推荐理由。别小看这个字段我实测在推荐卡片上显示因为你看过《代码大全》这类理由后点击率提升了约14%这个后面会细说。3. 核心功能模块设计与细节拆解3.1 图书分享模块从上传到触达的完整链路图书分享不能只做一个上传文件按钮完整链路是上传物料→生成书籍记录→产出分享入口链接或小程序码→接收方打开→进入个人书架。我把这条链路上的关键节点逐个过一遍。上传部分用OkHttp的MultipartBody同时处理进度回调。这里有一个特别容易踩的坑进度回调不能直接在RequestBody的writeTo里更新UI因为writeTo跑在OkHttp的IO线程池必须通过协程或Handler切回主线程。我的接口定义大概是这样Multipart POST(/api/books/upload) suspend fun uploadBook( Part file: MultipartBody.Part, Part(title) title: RequestBody, Part(category) category: RequestBody ): ApiResponseBookBean文件格式上我支持EPUB、PDF、TXT三种。EPUB是电子书行业标准格式但解析成本高TXT最轻量但体验差PDF兼容性最好但重排困难。所以上传阶段做格式校验只放行这三种。TXT还需要做编码识别我用juniversalchardet检测字符集避免中文乱码被用户喷。触达部分小程序端分享用微信原生的button open-typeshare配合onShareAppMessage返回路径和参数。Android端做了两种分享一种调用系统分享面板把PDF或EPUB通过FileProvider以content://方式分享给微信或QQ另一种是生成带小程序码的分享卡片图片存到相册让用户转发。实测下来小程序码的分享转化率远高于系统分享面板因为系统面板跳转到微信后用户还要手动选好友链路太长卡片图片则可以直接发朋友圈或群里。3.2 推荐系统的三个核心行为信号推荐效果好不好关键不是算法多花哨而是喂给算法的行为数据干不干净。我最终选了三个核心信号权重比例是阅读时长:收藏/点赞:分享 5:3:2。这个比例是调出来的最开始阅读时长占比过高导致用户只是挂着页面不动推荐结果明显跑偏后来才手动调成了现在的比例。阅读时长的采集方式App端每10秒上报一次阅读进度点小程序端用onHide和onUnload上报累计时长。计算时把单次超过4小时的会话视为异常数据剔除那是用户挂机产生的。收藏和点赞属于强正向行为但需要防刷同一个用户对同一本书最多计算一次收藏行为重复点击不重复加分。分享代表用户愿意用自己的社交关系为这本书背书质量信号很强权重给到了0.2。所有行为统一写入评分表(user_id, book_id, score, created_at)score由三个信号加权后映射到1到5分。注意这里不是让用户手动打星而是从行为里推断隐含评分这样后续ItemCF计算时可以直接拿到一个稠密的评分矩阵。3.3 用户体系与阅读轨迹同步双端产品最怕的是用户在两端的阅读进度对不上。我的做法是Android端绑定手机号或邮箱小程序端走微信授权登录wx.login拿code后端调微信接口换openid。两端账号通过phone和unionId字段关联合并用户数据时以unionId优先。阅读轨迹的核心结构是(book_id, chapter_id, position, device_type, update_time)每次阅读结束都会同步。这里有一个值得说的细节Android端阅读位置记录的是页码进度百分比小程序端因为用的是web-view渲染H5阅读器记录的是scrollTop滚动位置。同步到服务端时统一换算成0到1的百分比浮点数这样同一本书在两端打开位置基本能对得上。排版差异会导致位置偏移所以允许±2%容差超出才按新位置覆盖。这个方案上线后用户关于位置偏了的反馈明显减少了。4. Android端的三个关键实现与踩坑4.1 图书列表页与进度条的几种做法Android端首页是推荐流核心控件是RecyclerView。看着简单但第一次做推荐流的人容易忽略一个问题推荐卡片是复合item封面、标题、作者、推荐理由、进度条各占一层如果内部布局嵌套过深滑动帧率会非常难看。我的做法是用DiffUtil做数据差异计算图片统一走Glide并开小图预加载item布局层级控制在三层以内实测列表页滑动基本稳定在60帧。进度条是另一个容易被低估的控件。爱读书里进度条出现了三个场景上传实时进度、下载断点进度、阅读进度。三种形态完全不同上传用水平ProgressBar配百分比文字下载在封面右下角做成环形进度阅读进度是书架item底部一个很细的水平条。我封装了一个BookProgressView内部用ValueAnimator做平滑动画数据来源由外部回调输入。进度条最大的坑是回调里直接改UI导致崩溃或卡顿。OkHttp的上传回调跑在IO线程必须切到主线程。另外ProgressBar的max值一定要在数据来之前设置好不然进度算出来永远是错的。我见过有人把max设成默认的100结果进度值传进来一个500进度条直接跑到底看起来像卡死。4.2 文件分享与FileProvider那点破事这是我在Android端踩得最深的一个坑也是搜索热词里频繁出现content://的原因。Android 7.0之后应用间传递文件不能直接暴露file://绝对路径必须用FileProvider生成content://URI。第一次接入时我没注意用Intent(ACTION_SEND)分享PDF直接崩溃日志抛FileUriExposedException排查到凌晨才发现是这么回事。正确做法分三步。第一步在AndroidManifest声明FileProviderprovider android:nameandroidx.core.content.FileProvider android:authoritiescom.lovedu.android.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider第二步在res/xml/file_paths.xml里配置对外开放的路径cache-path namebook_cache path/ / external-files-path nameexternal_book pathbooks/ /第三步分享时用FileProvider.getUriForFile生成授权URI并加上FLAG_GRANT_READ_URI_PERMISSION。这里有个非常值得说的点每家的FileProvider路径都不一样搜索热词里能看到content://com.baidu.searchbox.fileprovider、content://com.tencent.wework.fileprovider、content://com.ss.android.uri.key这些前缀说明每个App的authority都是自定义的。所以你的App接收别人共享的文件时绝对不能写死解析规则必须用ContentResolver的openInputStream去读文件让系统帮你处理。另外微信接收文件后文件名有时会变成一串乱码原因是Intent里没设置EXTRA_TITLE实践方法是在Intent里putExtra(Intent.EXTRA_TITLE, fileName)尽可能让接收方拿到原始文件名。4.3 运行时权限与Android 13适配图书上传需要读存储空间这块的适配随着Android版本迭代已经变得有点复杂。我用一张表概括不同版本的处理思路。Android版本权限方案Android 6.0 - 9.0运行时权限READ_EXTERNAL_STORAGE动态申请Android 10分区存储开始生效直接读公共目录受限Android 11存在MANAGE_EXTERNAL_STORAGE但普通应用过审困难Android 13使用系统文件选择器OpenDocument免存储权限爱读书的处理策略是Android 13以上使用ActivityResultContracts.OpenDocument拉起系统文件选择器用户自己选书文件完全绕开存储权限Android 12及以下走运行时权限申请。这条路线最稳因为系统文件选择器不需要任何存储权限而且用户感知是安全的。本地书架功能在Android 13上通过MediaStore查询书籍文件只读不写所有写入都放到getExternalFilesDir()自己的应用目录里。5. 小程序端的体验优化与调试5.1 页面结构首页推荐流、书籍详情、我的书架小程序端我设计了五个主页面首页推荐流、分类页、书籍详情页、我的书架页、个人中心页。首页和个人中心用tabBar实现其余页面通过wx.navigateTo进入。首页推荐流是典型的滚动列表加骨架屏场景。数据从推荐接口拉回来后渲染逻辑按卡片结构走。推荐卡片包含封面、书名、作者、推荐理由、预测评分标签。这个预测评分其实是ItemCF算法算出来的分数转化来的格式显示为读者评分4.6用户会以为这是编辑评分。这个设计在心理层面很巧妙用户更信任已有评分而不是算法概率所以点击率更高。书籍详情页有两个核心功能在线阅读入口和分享卡片。在线阅读为兼容TXT和PDF小程序端使用web-view加载一个H5阅读器H5页面通过postMessage向小程序上报阅读进度。这里一定要做降级兜底如果H5加载失败或者文件格式不支持就提示用户去App阅读不能直接白屏。5.2 列表加载更多的防重与节流触底加载更多是搜索热词里反复出现的经典问题。我在第一个版本就踩了坑用户快速滑动到页面底部onReachBottom连续触发重复请求发出去返回的page1、page2、page3数据顺序全乱了。解决思路是给加载函数加isLoading门闩onReachBottom() { if (this.data.isLoading || this.data.isFinished) return; this.setData({ isLoading: true }); const page this.data.page 1; requestBooks(page, 10).then(res { this.setData({ bookList: this.data.bookList.concat(res.items), page: res.page, isLoading: false, isFinished: res.items.length 10 }); }); }同时还要处理下拉刷新刷新时把page重置为0、isFinished重置为false并且清空bookList否则新数据会叠在旧数据后面。这个小程序端的实现和Android端RecyclerView的分页加载本质是一样的核心都是请求完成前不再发起新请求。5.3 动态标题与生命周期监听的细节小程序端首页的导航栏标题不是写死的会根据用户当前浏览的分类动态变化。动态设置用wx.setNavigationBarTitle({ title: xxx })但这里有个隐藏前提页面json配置里的navigationBarTitleText不能为空如果为空动态设置会闪一下又变回去效果非常诡异。正确做法是页面json里写一个默认标题onLoad里再set。用户离开小程序的监听也是热词里的高频问题。监听离开有三个层次点左上角切后台触发onHide关闭小程序触发onUnload跳转其他小程序触发onHide和onShow成对变化。实测下来onHide是相对可靠的离开检测点它能同时覆盖切后台和跳转其他场景。但要记住切后台后微信App可能在几分钟内回收小程序此时再发同步请求可能已经来不及了。所以阅读进度上报一定要有本地缓存加下次启动补报机制onHide() { const progress this.data.readingProgress; wx.setStorageSync(pending_sync_ this.data.bookId, progress); // 尽力上报但不要依赖它一定成功 }5.4 小程序抓包联调实录联调阶段小程序抓包是必备技能。小程序跑在微信里常规的浏览器开发者工具看不到真实请求只能用代理方式抓包。我用的环境是Charles加手机同一WiFi设置手机HTTP代理指向PC的局域网IP和Charles端口8888然后安装Charles的HTTPS证书才能解开加密流量。具体步骤手机连上WiFi后手动设置HTTP代理代理地址是PC局域网IP端口8888手机浏览器访问chls.pro/ssl下载并安装证书iOS需要额外在设置里信任一次证书然后在Charles里Enable SSL Proxying添加要抓的域名。抓包时最常见的问题是开发者工具里请求正常真机上请求报错。这种情况十有八九是请求域名的TLS版本或证书链问题用Charles抓一下真机流量对比开发者工具请求能很快定位。还有一个小程序特有的坑真机上打开小程序后微信自带域名校验如果接口地址不是HTTPS或者没在合法域名清单里直接报url not in domain list。调试期可以在开发者工具里勾选本地设置-不校验合法域名但上线前一定要在微信公众平台后台把request合法域名配置好否则一发布线上就废。6. 推荐算法落地ItemCF从公式到代码6.1 一个手算案例理解ItemCF的完整流程推荐系统听起来高大上但核心算法其实不复杂。我用手算的方式把ItemCF完整跑一遍。假设现在有3个用户5本书隐含评分如下用户深入理解计算机代码大全算法导论数据结构操作系统U154054U245340U330405目标是为U3推荐他还没读的《代码大全》和《数据结构》。第一步把每本书表示成用户评分向量。比如《深入理解计算机》向量是(5,4,3)《代码大全》是(4,5,0)《算法导论》是(0,3,4)。第二步计算物品两两之间的余弦相似度。以《深入理解计算机》和《代码大全》为例两者内积 5×4 4×5 3×0 20 20 0 40《深入理解计算机》模长 √(25169) √50 ≈ 7.07《代码大全》模长 √(16250) √41 ≈ 6.40余弦相似度 40 ÷ (7.07×6.40) ≈ 0.884把关键相似度都算出来物品对相似度计算机-代码大全0.884计算机-数据结构0.906计算机-操作系统0.773算法导论-代码大全0.469算法导论-操作系统0.625操作系统-数据结构0.488第三步对U3未读的书分别用他读过的书打分做加权平均。U3读过的书和评分是计算机3算法导论4操作系统5。《代码大全》预测分 (3×0.884 4×0.469 5×0.391) ÷ (0.884 0.469 0.391) 6.483 ÷ 1.744 ≈ 3.72《数据结构》预测分 (3×0.906 4×0.5 5×0.488) ÷ (0.906 0.5 0.488) 7.158 ÷ 1.894 ≈ 3.78所以推荐位排序是《数据结构》第一《代码大全》第二。这个手算过程看着枯燥但它就是推荐系统的全部核心用历史行为找相似物品再用相似物品的评分预测目标物品的评分。落到工程上物品数量在1000本以内时直接用Java的HashMap就能完成相似度矩阵计算不需要上Spark重型框架。我实测单机JVM处理5000本书的相似度矩阵大概需要几十秒放定时任务里跑完全没问题。6.2 冷启动、归一化与热门兜底ItemCF有一个天生缺陷新用户没有行为数据算不出任何预测分。我做了三层兜底。第一层用户刚注册只产生一次阅读行为时立即用Rule-based规则推荐按用户选的分类标签比如选了Java或散文推同分类的高分书。第二层行为数据积累到至少5次有效记录才切换成ItemCF引擎。第三层首页推荐流永远混入10%的热门榜内容。热门榜按近7天下载量和阅读人数的加权综合排序。归一化这个环节也容易被忽略。阅读时长的数值范围和收藏次数差了不只一个数量级直接加权会让收藏信号被淹没。我在评分转换时用min-max归一化把每个信号压到0到1之间再乘以权重最终评分公式是score 0.5 * normalize(duration_minutes) 0.3 * normalize(favorite_count) 0.2 * normalize(share_count)某个信号特别极端时计算值可能超过5这时候直接截断到5。6.3 推荐结果的缓存与定时更新推荐结果不是实时算的否则每个用户的首页请求都要跑一遍全量相似度矩阵后端扛不住。我的方案是每天凌晨2点用Spring的Scheduled定时任务重新计算全部用户的推荐列表写入MySQL的recommend_cache(user_id, book_id, rank, reason, update_time)表同时清理Redis里对应的推荐key。用户请求首页推荐时先查Rediskey的设计是recommend:user:{userId}:page:{page}命中直接返回没命中就查MySQL的recommend_cache表按rank排序分页返回如果这个用户没有缓存结果说明是冷启动新用户直接回退热门榜接口。这个三级回退策略保证了任何情况下接口都有内容返回。定时任务跑完还有两件事必须做第一把用户行为表按7天窗口重新计算一遍评分避免很久以前的老数据继续影响新推荐第二清理recommend_cache表里超过30天的数据。不清理的话表膨胀非常快我实测运行3个月这个表涨到了几百万行不加清理连索引维护都成问题。7. 常见问题速查与实战心得7.1 常见问题速查表把项目过程中遇到的高频问题整理成一张表方便直接对照排查。问题现象根本原因解决办法小程序真机请求报url not in domain list接口域名未加到微信后台合法域名开发期勾选不校验合法域名上线前在后台配置小程序抓包看不到HTTPS请求内容手机未信任Charles根证书手机访问chls.pro/ssl下载证书并安装信任Android分享PDF崩溃FileUriExposedException用了file:// URI改用FileProvider生成content:// URI分享后微信文件名乱码Intent未设置EXTRA_TITLE设置EXTRA_TITLE并带正确MIME类型上传进度条不刷新上传回调在IO线程更新UI切换到主线程更新进度条列表触底加载重复请求onReachBottom多次触发没有拦截加isLoading门闩请求完成再放开推荐结果全是老书历史行为权重过高加7天时间窗口衰减新行为权重更高冷启动用户首页没推荐ItemCF面对空行为数据无法计算兜底热门榜推荐理由改为本周热门FileProvider被其他应用抢注册authority冲突自定义authority为包名fileprovider7.2 几个值得记住的实战心得第一个心得与双端协作有关。双端项目最容易出现的幻觉是两边各做各的最后对接接口但实际上接口返回结构只要在一边被改动另一边就得同步改非常痛苦。我后来把接口返回结构抽成一个共享的OpenAPI文档统一维护每次接口变更先在文档里改再同步两端代码联调效率提升非常明显。第二个心得是推荐理由带来的业务收益。在推荐卡片上展示因为你看过《代码大全》所以推荐这本之后点击率提升了约14%这是个非常明显的效果。后来我甚至把推荐理由做成可配置模板不同分类用不同文案风格技术书说你最近在读技术类书籍这本《Java核心技术》评价很高社科书说和你读过的《乌合之众》同类读者评分4.7。推荐理由这个小功能是实现成本极低但收益极高的一个优化点。第三个心得是做推荐系统时对数据质量的控制。项目里花时间最多的不是写算法代码而是保证行为采集的准确性。最开始阅读时长的上报是单纯每10秒打点结果发现用户把App挂在后台导致时长严重虚高推荐结果乱得没法看。后来改成只有前台可见时才累计同时监听前后台切换数据才变得可信。做推荐系统一定不要轻视脏数据问题算法再漂亮喂进去的数据是垃圾出来的结果也是垃圾。整个项目做下来最深的体会是双端阅读产品的核心不在某个端的具体控件而在数据链路是否完整。从Android端的文件读取、进度条到小程序端的分享卡片、抓包调试再到后端ItemCF算法、缓存策略每一环都要落实到用户行为→数据落库→算法计算→内容分发这条主线上。你把这条链路上任何一个环节做扎实产品都能往前推进一大步。
阅读完成 · 觉得有帮助?