简介:这是一套面向Android初、中级开发者的新闻阅读类项目源码,重点演示了书籍翻页效果的实现与升级改进,适合想要学习自定义View动画、页面切换交互的读者参考。资源包内共31个文件,包含Java源码、class字节码、XML布局与资源、数据库db文件、图片资源以及工程配置文件,还附带可安装的apk安装包,方便直接运行体验效果。压缩包体积仅242KB,短小精悍,便于快速下载查看。目前已有316人学习使用,属于轻量级但功能完整的教学型项目。通过阅读src目录下的源码,可以了解翻页算法、手势监听以及新闻列表与详情页的衔接逻辑;res目录中的布局和资源文件也展示了多密度适配的基本做法。作者还保留了编译生成的dex和apk文件,省去重新构建的麻烦,适合边看边调试。
1. 拿到“新闻阅读 书籍翻页效果(升级版)”源码包,先想清楚要改哪里
如果只是做一个新闻列表,上下滑动就够了;但标题带“书籍翻页效果”的小程序源码,多半是想在手机上复现纸质书/杂志的阅读体验。用户在新闻详情页里左右翻页,而不是按返回键退出去换下一篇。“升级版”这三个字一般指在初版基础上补了翻页阴影、双页排版、阅读进度保存,而不是换了一套翻页引擎。
下面按我的习惯把这类源码包从解压到改造成自己能用的过程过一遍:先用命令行把 rar 解出来,再在微信开发者工具里跑起来,然后定位翻页核心代码,最后把新闻数据接进去。这篇内容适合刚接触小程序的开发者,也适合在小程序里做图文阅读的老手。
2. 先解压 .rar,把项目完整导入微信开发者工具
2.1 用 unar 替代 unrar,省去中文文件名乱码问题
遇到.rar后缀,多数人第一反应是unrar x。在 Windows 上双击 WinRAR 没问题,但到了 macOS 或 Linux 服务器上,unrar默认不装。Debian/Ubuntu 系安装命令:
sudo apt install unrarmacOS 用户可以brew install unrar。装完执行:
unrar x 小程序源码-新闻阅读-书籍翻页效果-升级版.rar这一步最烦人的不是缺依赖,而是文件名乱码。老牌打包工具在 Windows 下默认按 GBK 编码文件名,macOS/Linux 终端按 UTF-8 解包,解出来经常是一堆类似灏忕▼搴忓師鐮�.js的乱码目录。这种目录名在微信开发者工具的project.config.json里一旦被引用,编译阶段就会直接报“文件不存在”。
我一般会改用unar,它能自动识别压缩包内的文件名编码,并转成当前系统编码:
# macOS brew install unar # Debian/Ubuntu sudo apt install unar unar 小程序源码-新闻阅读-书籍翻页效果-升级版.rarunar会生成一个以原压缩包名去掉扩展名命名的目录,文件保持中文可读。如果手边只有 Windows,用 7-Zip 打开.rar后在“选项”里把文件名编码切到 UTF-8 也能达到同样效果。
2.2 核对 project.config.json 和 appid
解压后不要先急着打开编辑器,先确认project.config.json里的appid字段。这个文件是微信开发者工具识别项目的入口,常见内容如下:
{ "miniprogramRoot": "./", "appid": "touristappid", "projectname": "news-flip", "setting": { "urlCheck": true } }appid写touristappid表示工具会以游客模式运行,很多 API 会被限制;留空则导入时会弹出 AppID 配置窗口。我一般建议先把 AppID 改成自己的测试号,这样云开发、订阅消息这类能力能提前验证。下面表格列出这类阅读源码包里最容易影响启动的文件,按重要性排:
| 文件 | 作用 | 需要特别检查的点 |
|---|---|---|
app.json | 页面注册、窗口外观、tabBar 配置 | pages里每个路径都必须真实存在 |
project.config.json | 项目级别配置 | appid、miniprogramRoot |
pages/reader/index.json | 阅读页单独配置 | 是否声明了navigationStyle: custom |
components/page-flip/ | 翻页组件 | usingComponents路径是否对得上 |
不少源码包是从某个模板项目改的,pages里可能残留与阅读无关的示例页面。先检查app.json的pages数组,删掉不存在或与本项目无关的页面路径,不然导入时会像这样报错:
未找到 app.json 中定义的 pages/xxx/index 对应的文件用jq可以快速验证 JSON 语法:
jq . app.json > /dev/null && echo json ok如果命令正常输出了json ok,说明app.json基本没断句问题;如果报 parse error,就打开文件补上缺失的逗号或括号。
2.3 先定位 project.config.json,避免启动就报目录错误
很多源码包解压后并不是一眼就能看到入口,嵌套目录很常见。先执行:
find . -name "project.config.json" -maxdepth 3 -print如果结果在二级目录,微信开发者工具的导入目录要选到那个目录,而不是最外层。如果最外层也有project.config.json,但miniprogramRoot指向了不存在的子目录,工具会提示“miniprogramRoot 不存在”。此时打开文件,把miniprogramRoot改成实际路径,比如miniprogram/。
这之后才是导入步骤:
- 微信开发者工具顶部菜单选择“项目 → 导入项目”
- 目录选择找到的
project.config.json所在目录 - AppID 选择“测试号”
- 点击“确定”,等依赖解析完成
导入后第一件事不是点预览,而是点“编译”,看页面是否正常渲染。如果控制台报“找不到 app.json 中的页面”,回 2.2 的pages数组检查。还有一部分老项目用的是旧版全局组件,打开后会遇到“未启用增强编译”的提示,先别急着开,详情面板的“本地设置”里默认配置能跑通,就优先保持默认。
3. 书籍翻页效果背后的 CSS 3D 原理与触摸交互
3.1 为什么升级版优先选择 CSS 3D 而不是 canvas 绘制
“书籍翻页效果”在小程序里有三种常见实现方式。第一种是用canvas每帧绘制页面和阴影,仿真度最高,但代码量大、低端机掉帧明显,且 canvas 的触摸坐标换算在小程序里还要手动处理 canvas 到页面的缩放。第二种是用movable-view做拖拽位移,老实说那更像是刷卡,不是翻书。第三种是 CSS 3D 变换:把左右两半页面分别放在一个 3D 容器里,通过改变rotateY让页面像书页一样立起来再倒下去。
“升级版”源码包里十有八九采用第三种。原因很直接:小程序渲染层对transform和opacity底层做了合成器优化,不会触发主线程重排;配合transition一条声明就能获得平滑动画,不需要写定时器。
CSS 3D 翻页的核心是三个属性:
perspective:给容器设置透视,值越小透视越强,页面翻转时近大远小越明显;transform-style: preserve-3d:让子元素按 3D 空间呈现,而不是被压扁到平面;backface-visibility: hidden:页面翻转到超过 90 度后隐藏,避免出现背面镜像内容。
3.2 最小翻页组件的 WXML 与 WXSS
一个能演示动画的翻页组件只需要两个半页。WXML 结构如下:
<view class="book" catch:touchstart="onTouchStart" catch:touchend="onTouchEnd"> <view class="page left-page" style="transform: rotateY({{leftAngle}}deg);"></view> <view class="page right-page" style="transform: rotateY({{rightAngle}}deg);"></view> <view class="shadow"></view> </view>对应的核心样式:
.book { position: relative; width: 700rpx; height: 1000rpx; margin: 100rpx auto; perspective: 2000rpx; transform-style: preserve-3d; } .page { position: absolute; top: 0; width: 50%; height: 100%; backface-visibility: hidden; transition: transform 0.45s ease; background: #fff; } .left-page { left: 0; transform-origin: left center; /* 沿左边缘翻转 */ } .right-page { right: 0; transform-origin: right center; } .shadow { position: absolute; inset: 0; box-shadow: 4rpx 0 16rpx rgba(0,0,0,0.3); }这里left-page的旋转轴是左边缘,当leftAngle从 0 变为 -180 时,页面像左半页一样翻起;right-page的旋转轴是右边缘,从 0 变为 180 时,右半页往左倒。transition: transform 0.45s ease决定动画用时,想要更接近纸质书的手感,可以改成cubic-bezier(0.22, 1, 0.36, 1),后半段回弹更干脆。
3.3 touch 手势判定与动画锁
只有 CSS 还不够,升级版要能根据手指滑动方向决定翻哪一页。常见做法是在容器上监听touchstart、touchmove、touchend,计算手指位移的方向和距离。这里给一个只依赖 start 和 end 的简化版本:
// reader.js 中管理翻页状态 const RIGHT_PAGE = 1; const LEFT_PAGE = 2; Page({ data: { leftAngle: 0, rightAngle: 0, currentPage: 1 }, onLoad() { this.animating = false; // 动画锁,避免连续触发 this.startX = 0; this.startY = 0; }, onTouchStart(e) { if (this.animating) return; const touch = e.touches[0]; this.startX = touch.clientX; this.startY = touch.clientY; }, onTouchEnd(e) { if (this.animating) return; const touch = e.changedTouches[0]; const dx = touch.clientX - this.startX; const dy = touch.clientY - this.startY; // 横向位移大于竖向,且超过 40px 才触发翻页 if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 40) { if (dx < 0) this.flipPage(RIGHT_PAGE); else this.flipPage(LEFT_PAGE); } }, flipPage(direction) { this.animating = true; if (direction === RIGHT_PAGE) { this.setData({ leftAngle: -180, currentPage: this.data.currentPage + 1 }); } else { this.setData({ rightAngle: 180, currentPage: Math.max(1, this.data.currentPage - 1) }); } setTimeout(() => { this.animating = false; this.setData({ leftAngle: 0, rightAngle: 0 }); }, 450); } });这段代码里touchstart只记录起点,touchend才判断方向。Math.abs(dx) > Math.abs(dy)保证用户上下滑动新闻时不会误触发翻页;40px是一个比较稳的经验值,太小容易误触,太大则快速滑动没反应。animating是核心:翻页动画进行中不响应任何新 touch,否则动画还没结束,下一次setData又来了,页面会卡在半翻转状态。
实际源码会在重置角度的setData里把新一页的文本和图片同时换掉,上面只演示动画状态机。换内容的操作要放在flipPage那个 450ms 的setTimeout里,和角度重置同一批次执行,避免出现“页面翻过去了但内容还是旧的”的闪烁。
3.4 升级版补的阴影、双页和进度记忆
初版效果往往只有页面旋转,升级版会在动画过程中补一个阴影层。更精细的做法是绑定transitionend事件,在页面翻起时增加对侧页面的明亮度:
this.setData({ leftAngle: -180, shadowOpacity: 0.25 });阴影用渐变模拟:
.book-shadow { position: absolute; top: 0; left: 50%; width: 50%; height: 100%; background: linear-gradient(90deg, rgba(0,0,0,0.18), transparent); opacity: 0; pointer-events: none; }双页模式是把内容同时排到左右两半,left-page放当前页左侧,right-page放右侧,翻页后页码 +2。阅读进度记忆一般放在onHide和onUnload里写入本地存储,这样用户退出小程序再进来,还能回到上次停住的位置。这三样做完,基本就配得上“升级版”三个字了。
4. 新闻数据怎么塞进书页并保持分页稳定
4.1 长新闻按页拆分的测量方案
书籍翻页组件本身只负责动画,内容既可以直接每页一条新闻,也可以把一篇长新闻按高度拆成多页。后者是新闻阅读场景的难点:新闻文本长度不定、图片大小不定,拆错了要么最后一页空一截,要么内容被截断。
一个稳定可复现的思路是:先把完整新闻渲染到一个看不见的容器里,拿到总高度,再除以单页高度。下面是封装在页面里的测量方法:
measureNewsToPages() { return new Promise((resolve) => { const query = wx.createSelectorQuery(); query.select('#article-hidden').fields({ size: true, scrollOffset: true }, (res) => { if (!res) { resolve({ pages: 1 }); return; } const pageHeight = this.data.readerHeight || 1; // 单页显示高度,单位 px const totalHeight = res.scrollHeight || res.height; const pages = Math.ceil(totalHeight / pageHeight); resolve({ pages, totalHeight, pageHeight }); }).exec(); }); }解释一下:#article-hidden是页面上一个固定宽度但高度设为auto的隐藏节点,把新闻 HTML 通过rich-text渲染在里面。fields的scrollHeight能拿到内容滚动高度,即使容器本身没有限制高度,只要内容溢出了,这个值就是内容的实际高度。得到pages后,读者可以一次翻一页,也可以一次翻一屏。注意这里必须等rich-text渲染完成后再测量,所以调用measureNewsToPages前要先wx.nextTick。
有一个容易被忽略的细节:如果这个测量方法写在自定义组件里,wx.createSelectorQuery()后面要加.in(this),否则 select 查的是整个页面而不是组件内部。页面里使用则不需要。
4.2 列表到详情的消息传递与进度记录
新闻列表到阅读页,常见的数据流是跳转时带一个newsId,阅读页根据newsId去本地缓存或服务端拉全文。这里不建议直接把整篇内容放到 URL 参数里,小程序url长度有限,内容一长会被截断。我一般会在新闻列表页里:
const currentNews = this.data.newsList[index]; wx.setStorageSync('currentNews', currentNews); wx.navigateTo({ url: '/pages/reader/index?newsId=' + currentNews.id });阅读页onLoad拿到newsId后,先从本地缓存恢复进度:
const saved = wx.getStorageSync('reading_' + newsId); if (saved && saved.pageNo) { this.setData({ currentPage: saved.pageNo }); }这样每翻一页记录一次,写进 Storage 的频率要控制,不能每次setData都同步写。常见做法是用“节流”:页码变化后只更新内存,onHide或onUnload时一次性写盘。
数据字段可以参考下面这张表,覆盖新闻阅读的最小集合:
| 字段 | 类型 | 说明 |
|---|---|---|
newsId | string | 新闻唯一 ID,用于进度 key |
title | string | 标题 |
source | string | 来源名称 |
publishTime | string | 发布时间 |
content | string | HTML 片段 |
coverImages | array | 封面图 URL 列表 |
pageNo | number | 记录当前翻到第几页 |
如果新闻接口返回的是纯文本,需要先转成简单的 HTML 结构:用<p>包住每一个段落,图片用<img>标签。rich-text组件对部分标签支持有限,<video>、<iframe>这类不建议直接放进去。
4.3 字号调整后的重排防抖
新闻阅读器几乎都要支持调字号。字号一旦变化,原先测量出的页数就作废,必须重新测量。如果用户在快速调大的过程中连续点击“+”,测量函数可能被触发十几次,最后拿到的高度还是旧字号渲染的结果。
解决办法是加一个防抖:每次调整字号只更新data,等字号稳定 300ms 后再重新测量分页。小程序没有现成的 debounce,可以这样写:
remeasure() { if (this.timer) clearTimeout(this.timer); this.timer = setTimeout(() => { this.setData({ pageContent: this.data.content }, () => { wx.nextTick(() => { this.measureNewsToPages().then(({ pages }) => { this.setData({ totalPages: pages, currentPage: 1 }); this.animating = false; }); }); }); }, 300); }这里的setData会触发rich-text重新渲染,wx.nextTick保证在渲染完成后读取scrollHeight,否则读到的还是上一次高度。按经验,字号按钮放在屏幕右侧竖排,每按一次就调用remeasure,页面不会出现“内容翻到一半突然多出几页”的尴尬。图片加载也要做同样处理:图片加载完成事件里重新触发一次remeasure,否则图文混合的新闻会在图片加载前后页数不一致。
5. 真机调试时专门验证翻页效果的四个习惯
5.1 先看性能面板里的 FPS,再决定要不要优化
微信开发者工具的“性能 Warnings”面板只能参考,最好用真机调试。打开调试图,切到“性能”标签,开始录制,然后连翻十几页,观察帧率曲线。如果低于 40 FPS,先检查翻页动画里是否滚动到了setData。翻转过程中只应改transform,像width、height、top这类属性一旦频繁变化,浏览器会做代价更高的布局计算。
5.2 用图片按需加载控制内存
新闻页经常大图多,翻页后不可见的页面仍然持有图片的渲染对象。可以用wx.createIntersectionObserver监听每个.book-page节点与屏幕的交叉状态,离开视口后把图片src置空:
const observer = wx.createIntersectionObserver(this); observer.relativeTo('.book').observe('.book-page', (res) => { const isVisible = res.intersectionRatio > 0; const imgSelector = '#page-' + res.dataset.pageIndex + '-image'; if (isVisible) { this.setData({ [imgSelector]: this.data.realSrc }); } else { this.setData({ [imgSelector]: '' }); } });这样不可见页面不会一直占着图片解码后的内存。注意dataset的值只能从 WXML 里的style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />