简介:这份《新骆驼IPTV完美版后端源码 APP源码》面向安卓手机与电视盒子端的电视直播应用开发者,提供前后端完整代码参考,便于理解IPTV系统结构并进行二次开发或功能定制。压缩包体积约21.72MB,文件总数登记为0、类型明细暂未提供,但从标题与描述可确认主体为后端服务源码与安卓APP源码两部分,涉及频道与播放列表接口、数据库存储、认证授权、流媒体分发,以及客户端界面、播放器集成、网络请求和遥控器适配等模块。已有约4600人学习下载,具备一定参考热度。读者可据此梳理IPTV源接入到客户端播放的调用链路,学习RESTful API设计、用户权限控制、视频流协议与播放缓冲处理;后端侧可参考服务器请求处理、频道数据组织与流媒体服务配置,前端侧可关注播放器缓冲策略、JSON数据解析与遥控器焦点控制,适合具备安卓或后端基础、希望搭建电视直播平台的中级开发者查阅。
1. 一套 IPTV 双端源码省掉的是什么:从源管理到客户端起播的最小闭环
某园区要做一面内部电视墙,采购按点位报价,算下来不如自己搭一套。这类需求最后大多落到同一个东西上:一套 IPTV 双端源码(后端 + APP)。后端负责把 iptv 源,也就是 m3u / m3u8 播放列表,收进来做分类、去重、排序,对外只暴露登录、频道列表、取播放地址、节目单四五个接口;APP 端拿列表渲染成频道格子,点进去直接起播。这套包真正省时间的地方不在于播放器写得多漂亮,而在于「源 → 频道 → 客户端」这条链路的骨架已经搭好了,数据库表、接口字段、鉴权头都是通的,你只需要往里填自己的源和自己想要的界面。适合想自建可控直播/点播系统、想研究双端接口对接、手里有现成播放源要管的开发者;不适合只想双击装个 App 就能看的人,那类需求用现成播放器就够了。
2. 拆包先认三层:后端目录、APP 工程与数据库核心表
压缩包解开之后,别急着找启动脚本。这类「后端 + APP」的双端包,结构通常分成三个互相独立的部分,认清楚哪层归哪层,后面改代码才不至于在后端目录里找 Android 的布局文件。我拿到一个新包的习惯是先把顶层目录列一遍,再用文件大小排个序,看看有没有明显的「大头」——通常那个大头就是后台管理前端或者播放器 SDK。
2.1 后端目录:入口文件、应用层与扩展层
后端绝大多数是 PHP 系(ThinkPHP 5.x 或 Laravel 风格)或者 Spring Boot,看根目录有没有composer.json、pom.xml、artisan、think这几个文件就能判断。PHP 包的典型目录长这样,你的包里名字可能略有出入,但分层逻辑一致:
backend/ ├── public/ # Web 根目录,index.php 是唯一入口 ├── application/ # 应用层:api 模块、admin 模块分开 │ ├── api/ # 给 APP 用的接口 │ │ ├── controller/ # 控制器:Channel.php、User.php、Epg.php │ │ ├── service/ # 业务层:SourceService、SignService │ │ └── middleware/ # AuthToken.php 鉴权中间件 │ └── admin/ # 后台管理,加频道、导源、看设备 ├── config/ # 数据库、缓存、签名密钥 ├── extend/ # 第三方扩展,播放器 SDK、m3u 解析库常在这 ├── runtime/ # 缓存和日志,注意要可写 └── .env # 数据库四件套,跑之前必改这里有个容易看漏的点:application/api和application/admin是两套控制器,共用同一批 model。很多新手改接口改到admin目录下的方法去了,结果 APP 行为纹丝不动,查半天以为是缓存问题。判断方法很简单,看路由文件里api/v1这个分组绑的是哪个模块。另外extend/里常常塞了去广告、源探测之类的脚本,二次开发前先扫一眼,不用的删掉,能少一堆依赖冲突。
2.2 APP 工程:Android 模块划分与播放器依赖
APP 端以 Android 原生 Java/Kotlin 工程居多,也有 uni-app 或 Flutter 的壳。原生工程看settings.gradle里 include 了几个模块,常见的拆法是app(主壳)+player(播放器封装)+common(网络、工具)。播放器依赖是重点,看build.gradle里引的是 ExoPlayer、ijkplayer 还是系统 MediaPlayer:
dependencies { // 版本以你包里的 gradle 为准,别照抄,先看本地缓存有没有 implementation 'com.google.android.exoplayer:exoplayer-core:2.19.1' implementation 'com.google.android.exoplayer:exoplayer-hls:2.19.1' // 播 m3u8 必须有 implementation 'com.google.android.exoplayer:exoplayer-ui:2.19.1' implementation 'com.github.bumptech.glide:glide:4.16.0' implementation 'com.squareup.okhttp3:okhttp:4.11.0' }exoplayer-core只负责容器和音视频轨,m3u8 的切片解析在exoplayer-hls这个独立扩展里。见过不少人只引 core,起播转圈到超时,日志里报UnrecognizedInputFormatException,其实就是少这一行。Glide 用来加载频道台标,很多包为了省体积自己写了个图片缓存,效果一般,换成 Glide 是常见改造点。OkHttp 一般被包在common模块的网络封装里,留意它有没有做统一加 header,token 通常在那注入。
2.3 数据库三张表:频道、源、用户设备
数据库结构决定你后面改功能顺不顺手。这类项目核心表其实就三张:频道表、源表、用户设备表。源表存着 m3u 解析后的原始地址,频道表通过source_id引用源,这样一个频道可以挂主备两条源:
CREATE TABLE `iptv_channel` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `name` varchar(64) NOT NULL COMMENT '频道名,对应 m3u 里的 tvg-name', `group_id` int unsigned NOT NULL DEFAULT 0 COMMENT '分类 id', `logo` varchar(255) NOT NULL DEFAULT '', `source_id` int unsigned NOT NULL DEFAULT 0 COMMENT '主用源', `backup_source_id` int unsigned NOT NULL DEFAULT 0 COMMENT '备用源,主源超时切这条', `sort` int NOT NULL DEFAULT 0, `status` tinyint NOT NULL DEFAULT 1 COMMENT '1 上架 0 下架', PRIMARY KEY (`id`), KEY `idx_group` (`group_id`, `status`, `sort`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;idx_group这个联合索引是列表接口的性能命门。APP 首页一次请求就是「按分类取上架频道,按 sort 排」,没这个索引,几百个频道加几千条源,首次加载能拖到两三秒。backup_source_id这个字段很多粗糙的包干脆没有,只有一条源,源一挂频道就废。如果你的包里缺这个字段,加字段 + 在取地址逻辑里做降级,是性价比最高的一个改造,具体写法在第 3 章。
2.4 环境清单与启动命令:跑之前先对齐版本
环境这件事别嫌啰嗦,PHP 7.4 的包丢到 PHP 8.2 上跑,each()、动态属性这些老写法会直接白屏。先看composer.json里的require段和README或部署说明,把 PHP 版本、MySQL 版本对上。常见组合是 PHP 7.4 / MySQL 5.7 / Redis 5+,Redis 不是必须的,但列表缓存基本都靠它,没有的话要把缓存配置改成 file。
# 1. 装依赖,不要带 dev,能少一半体积 cd backend composer install --no-dev # 2. 复制配置并修改数据库连接 cp .env.example .env # 编辑器打开 .env,改 DB_HOST / DB_DATABASE / DB_USERNAME / DB_PASSWORD # 3. 导入表结构和初始数据 mysql -u root -p your_db < database/install.sql # 4. 起服务,0.0.0.0 是为了让手机能连进来 php think run -H 0.0.0.0 -p 8000Laravel 风格的包把第 3、4 步换成php artisan migrate和php artisan serve --host=0.0.0.0 --port=8000。-H 0.0.0.0这参数特别关键,默认只监听127.0.0.1,模拟器能通、真机一定连不上,这个坑后面还会提。runtime/或storage/目录记得给写权限,不然日志写不进去,报错信息全丢,排查的时候就是黑匣子。
3. 后端链路:一次频道列表请求从鉴权到签名的完整走法
后端的活儿说到底就一句话:把数据库里的频道,变成客户端能直接setMediaItem的地址。听着简单,中间有三道关——请求得先被放行,源得被解析成结构化数据,地址还得带上过期签名防止被人抓包外传。这三段串起来,就是整个后端最值得看的部分。
3.1 路由与 token 鉴权:请求怎么被放行
先把入口收敛。APP 端不需要知道你有多少控制器,它只需要四五个固定路径。路由文件通常长这样:
<?php // route/route.php Route::group('api/v1', function () { Route::post('login', 'api/User/login'); // 设备号/账号换 token Route::get ('channel', 'api/Channel/lists'); // 分类 + 频道列表 Route::get ('channel/:id/play', 'api/Channel/play'); // 取单个频道的可播地址 Route::get ('epg', 'api/Epg/today'); // 当日节目单 })->middleware('AuthToken'); // 整组挂鉴权,别一个个加把中间件挂在group上而不是单个路由上,是为了防止以后加接口忘了加鉴权——这种事我见过不止一次,新加一个channel/hot接口忘了挂,被人扫到直接白嫖带宽。鉴权本身通常不用查库,token 里自带 uid、过期时间、签名,验签就行:
<?php namespace app\api\middleware; class AuthToken { public function handle($request, \Closure $next) { $token = $request->header('X-Token', ''); if ($token === '') { return json(['code' => 401, 'msg' => 'missing token'], 401); } $parts = explode('.', base64_decode($token)); if (count($parts) !== 3) { return json(['code' => 401, 'msg' => 'bad token'], 401); } [$uid, $expire, $sign] = $parts; // 用和登录时相同的 key 重算签名,再做定长比较 $expect = hash_hmac('sha256', $uid . '.' . $expire, config('app.token_key')); if ((int)$expire < time() || !hash_equals($expect, $sign)) { return json(['code' => 401, 'msg' => 'token expired'], 401); } $request->uid = (int)$uid; // 往后传给控制器 return $next($request); } }几个参数说明一下。X-Token是自定义头,有的包用Authorization: Bearer,改的时候两边要一起改,后端改了 APP 没改就是全量 401。$expire是绝对时间戳,APP 端要处理 401 后重新登录,别把 token 写死进代码。hash_equals而不是==,是为了防时序攻击,虽然这种项目一般没人盯,但写上不费事。签名的 key 放config/app.php里,别硬编码在中间件,换 key 的时候你会感谢自己。
3.2 iptv 源聚合:把 m3u 解析成客户端直接能播的结构
这是整个后端最有含金量的部分。iptv 源一般是 m3u 文本,格式是#EXTINF描述行加一行地址,交替出现。解析器要能扛住各种不规范写法:描述行里可能带tvg-name、group-title、tvg-logo,也可能什么都没有只有一个逗号后的名字:
<?php namespace app\api\service; class SourceService { /** * 解析 m3u 文本,返回 [频道名 => 播放地址] * @param string $raw m3u 文件全文 */ public function parseM3u(string $raw): array { $out = []; $name = ''; foreach (preg_split('/\r\n|\r|\n/', $raw) as $line) { $line = trim($line); if ($line === '') { continue; } if (stripos($line, '#EXTINF') === 0) { // 优先取 tvg-name,没有就取最后一个逗号之后的内容 if (preg_match('/tvg-name="([^"]*)"/i', $line, $m)) { $name = trim($m[1]); } elseif (($pos = strrpos($line, ',')) !== false) { $name = trim(substr($line, $pos + 1)); } continue; } if ($line[0] === '#') { continue; // #EXTVLCOPT 之类的附加指令直接跳过 } if ($name !== '') { $out[$name] = $line; // 同名频道后出现的覆盖前面的 $name = ''; } } return $out; } }preg_split同时兼容\r\n和\n,因为很多源是在 Windows 上生成的。stripos(...) === 0判断行首,用===是因为stripos返回 0 时用==判断会踩假值坑。同名覆盖这个策略是刻意的——一个源文件里同一个频道出现多次很常见,后面的通常是更新过的地址。解析完还不算完,地址可能是相对路径,要拼上源站域名,这一步漏了,客户端拿到的就是/live/xxx.m3u8这种废地址。
聚合到频道列表时,主源取不到就降级到备用源,两条都没有的直接不下发:
public function lists(int $groupId = 0): array { $key = 'ch:list:' . $groupId; $data = cache($key); if ($data) { return $data; } $query = db('iptv_channel')->where('status', 1)->order('sort desc, id asc'); if ($groupId > 0) { $query->where('group_id', $groupId); } $rows = $query->field('id,name,group_id,logo,source_id,backup_source_id')->select(); $data = []; foreach ($rows as $r) { $url = $this->resolveUrl($r['source_id']); // 先试主源 if (!$url) { $url = $this->resolveUrl($r['backup_source_id']); // 主源挂了走备用 } if (!$url) { continue; // 两条都不可用,这个频道不下发,避免客户端点进去转圈 } $data[] = [ 'id' => $r['id'], 'name' => $r['name'], 'logo' => $r['logo'], 'url' => $this->sign->signUrl($url, 600), // 10 分钟有效期 ]; } cache($key, $data, 300); // 缓存 5 分钟,别让每个用户都打库 return $data; }$groupId = 0表示取全部分类,APP 首页一般先请求一次全量再本地分组,比每个分类发一次请求省事。缓存 300 秒是个经验值,太短起不到作用,太长后台改了频道要等五分钟才生效,调试阶段可以把cache()那行先注释掉。注意signUrl的 TTL 用的是 600 秒,比缓存时间长,是为了让缓存里的地址在缓存有效期内始终可用,这个配合关系别搞反。
3.3 播放地址签名:防盗链与过期控制
频道列表里的地址如果原样给出,被人抓一次包就能长期白用你的带宽。常见做法是给地址加过期时间和签名,由 Nginx 或 CDN 侧做校验:
<?php namespace app\api\service; class SignService { /** * @param string $url 原始播放地址 * @param int $ttl 有效秒数,至少覆盖一次完整观看 */ public function signUrl(string $url, int $ttl = 600): string { $expire = time() + $ttl; $path = parse_url($url, PHP_URL_PATH) ?: '/'; // 只签路径,不签 query $sign = md5(config('app.play_key') . $path . $expire); $sep = strpos($url, '?') === false ? '?' : '&'; return $url . $sep . 'e=' . $expire . '&sign=' . $sign; } }只对path签名而不是整个 URL,是因为原始地址常常自带?token=xxx这类参数,整个签会把 query 顺序也算进去,源站稍微一变签名就全废。$ttl给 600 秒看着短,但要想到 HLS 是分片播放,客户端每拉一个 ts 分片都会带着同一个 manifest 地址参数,如果只签 manifest 不签分片,600 秒够看很久;如果分片地址也要单独签,那 TTL 就得按单次观看时长来给,至少 3600 秒,否则看到一半断流,这个现象在第 5 章会展开。密钥play_key要和 Nginx 的secure_link_md5或 CDN 的鉴权配置完全一致,字符差一个就是全量 403。
4. APP 端链路:从列表点击到 HLS 起播的三个关键封装
后端把数据准备好之后,客户端的活儿看着轻,其实坑都藏在生命周期里。列表刷新、图片加载、播放器创建销毁、切台时的资源回收,哪一环没处理好,表现出来都是「用一会儿就卡」。这一章按一次真实点击的顺序走一遍。
4.1 列表页:RecyclerView 绑定与本地缓存
频道列表一般用 RecyclerView 加 GridLayoutManager,三列或四列。Adapter 写起来没什么花活,关键是别在onBindViewHolder里做网络请求或解析:
public class ChannelAdapter extends RecyclerView.Adapter<ChannelAdapter.VH> { private final List<Channel> data = new ArrayList<>(); private final OnItemClick click; public interface OnItemClick { void onPlay(Channel c); } public ChannelAdapter(OnItemClick click) { this.click = click; } public void submit(List<Channel> list) { data.clear(); data.addAll(list); notifyDataSetChanged(); // 几百条规模,全量刷新够用 } @Override public VH onCreateViewHolder(ViewGroup parent, int type) { View v = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_channel, parent, false); return new VH(v); } @Override public void onBindViewHolder(VH h, int pos) { Channel c = data.get(pos); h.name.setText(c.name); Glide.with(h.logo) .load(c.logo) .placeholder(R.mipmap.ic_channel) .into(h.logo); h.itemView.setOnClickListener(v -> click.onPlay(c)); } @Override public int getItemCount() { return data.size(); } static class VH extends RecyclerView.ViewHolder { TextView name; ImageView logo; VH(View v) { super(v); name = v.findViewById(R.id.tv_name); logo = v.findViewById(R.id.iv_logo); } } }submit里用notifyDataSetChanged而不是 DiffUtil,是因为频道列表一次全量替换,没有局部更新场景,用 DiffUtil 反而增加复杂度。台标一定要给placeholder,否则图片没加载出来那几秒是一片白块,观感很差。列表数据建议在SharedPreferences或本地文件里存一份 JSON,冷启动先渲染缓存再请求网络,这样断网时至少还能看到上次的列表——很多人第一次打开 App 看到空白页,就是没做这层兜底。
4.2 播放器封装:ExoPlayer 生命周期与切台不黑屏
播放器这块最容易翻车。最常见的写法是每次点击new ExoPlayer,退出页面不 release,切十几个台之后内存飙升、解码器被占满,直接黑屏。正确姿势是单例复用,切台只换 MediaItem:
public class PlayerHolder { private ExoPlayer player; private final PlayerView view; public PlayerHolder(PlayerView view) { this.view = view; } public void play(String url) { if (player == null) { player = new ExoPlayer.Builder(view.getContext()) .setLoadControl(new DefaultLoadControl.Builder() // 最小缓冲 3s、最大 15s、起播 1s、断流重缓冲 2s .setBufferDurationsMs(3000, 15000, 1000, 2000) .build()) .build(); view.setPlayer(player); } player.stop(); // 先停旧的,再换新的 player.setMediaItem(MediaItem.fromUri(Uri.parse(url))); // 带签名的新地址 player.prepare(); player.setPlayWhenReady(true); } public void release() { if (player != null) { player.release(); player = null; view.setPlayer(null); // 必须解绑,否则 Surface 泄漏 } } }setMediaItem是 ExoPlayer 2.13 之后的 API,老包里可能是prepare(mediaSource)那种写法,改造时注意版本。setBufferDurationsMs四个参数依次是最小缓冲、最大缓冲、起播缓冲、重缓冲,弱网环境下把起播缓冲调小能让首帧更快出来,代价是更容易卡顿,要按自己源的质量调。release()里view.setPlayer(null)这一行很多人漏掉,PlayerView 持有 Surface,不解绑就是实打实的内存泄漏,反复进出播放页几次就 OOM。生命周期上,onPause调player.pause(),onDestroy调release(),别在onStop里 release,切后台回来看个消息就得重新缓冲。
4.3 联调:改 baseUrl、打 release 包、真机验证
APP 连后端只有一处要改,就是接口根地址。这类工程一般把它抽在ApiConfig或Constants里:
public class ApiConfig { // 真机调试写局域网 IP,写 localhost 手机连的是它自己 public static final String BASE_URL = "http://192.168.1.20:8000/api/v1/"; public static final String HEADER_TOKEN = "X-Token"; }192.168.1.20换成你开发机在局域网里的实际地址,电脑和手机必须在同一个网段。Android 9 以后默认禁止明文 HTTP,AndroidManifest.xml里要加android:usesCleartextTraffic="true",或者配 network security config,只对调试域名放行。打包用./gradlew assembleRelease,debug 包和 release 包的签名、混淆配置不一样,有些包在 release 下因为混淆把反射用到的 model 字段名改掉了,JSON 解析全变 null,这个必须在真机上用 release 包验一遍,别只在模拟器 debug 跑通就交付。
5. 避坑排查:五个把人卡到半夜的现场问题
下面这几条都是这类双端项目里高频出现的现场故障,按「现象 → 原因 → 解决」写,遇到了可以直接对照。
5.1 接口 200 但频道列表是空的
现象:Postman 请求频道接口返回code: 0,data是空数组,日志里也没有报错。
原因:九成出在源解析。要么.m3u文件里的地址是相对路径,解析出来是/live/xxx.m3u8,resolveUrl拼域名那步没做,客户端拿到废地址;要么源地址后缀不是.m3u8,而聚合逻辑里写死了只认.m3u8结尾,把.flv、.ts全过滤掉了;还有一种情况是频道表status字段默认 0,导入数据时没置 1,查询条件where('status', 1)直接把全部记录筛没了。
解决:在parseM3u之后、入库之前打个日志,把解析出的前十条地址打出来看格式;resolveUrl里判断strpos($url, 'http') !== 0就补源站域名;去掉后缀过滤,改成协议白名单(http/https);导入 SQL 之后跑一句UPDATE iptv_channel SET status = 1。这三步查完基本能定位。
5.2 起播只有声音没有画面
现象:点进频道,声音正常出来,画面一直黑或者停在台标,进度条在走。
原因:编码格式不支持。源是 H.265/HEVC 编码,而工程里引的 ExoPlayer 版本没开 HEVC 软解;或者干脆是缺exoplayer-hls扩展,core 模块不认 m3u8 的切片描述,音频轨能勉强解析出来,视频轨直接失败。
解决:先看build.gradle里有没有exoplayer-hls,没有就加;加了还不行,用ffprobe看一下源的编码,H.265 的话要么换源,要么在 ExoPlayer 里配置DefaultRenderersFactory.setExtensionRendererMode(EXTENSION_RENDERER_MODE_PREFER)并引入 ffmpeg 扩展。最省事的办法是后端做一层转码,统一输出 H.264 的 HLS,客户端就不用管编码差异了,代价是服务器要扛转码开销。
5.3 真机连不上后端
现象:模拟器一切正常,装到真机上所有接口超时,APP 里提示网络错误。
原因:三个高频点。一是BASE_URL写的localhost或127.0.0.1,在手机上指向手机自己;二是后端php think run没加-H 0.0.0.0,只监听本机回环;三是开发机防火墙拦了 8000 端口,或者手机和电脑不在同一个 Wi-Fi(一个连 5G 一个连 2.4G,跨了网段)。
解决:先用手机浏览器直接访问http://开发机IP:8000/api/v1/channel,能看到 JSON 说明网络通,问题在 APP;看不到就查后端监听地址和防火墙。Windows 上netstat -ano | findstr 8000看监听的是不是0.0.0.0:8000,是127.0.0.1:8000就说明启动参数不对。
5.4 切台几次就黑屏或卡死
现象:连续切十几个频道之后,画面不再刷新,或者直接闪退,日志里有OutOfMemoryError或者MediaCodec相关报错。
原因:每次播放都new ExoPlayer,页面退出没release,解码器实例越积越多。Android 的硬件解码器数量有限,超过上限就申请不到新的,表现就是黑屏。
解决:按 4.2 节的写法改成单例复用,play()里先stop()再换MediaItem,release()里解绑PlayerView。可以用 Android Studio 的 Profiler 看内存曲线,正常复用的情况下切台内存是一条平稳的线,不复用就是一格格往上跳。
5.5 播到一半突然断开
现象:频道看五六分钟之后画面卡住,重连几次又好了,或者直接报 403。
原因:播放地址签名过期。签名 TTL 设了 600 秒,但 HLS 播放器在播放过程中会不断请求新的分片,如果分片地址也走同一套签名校验,超过 600 秒之后的请求全部 403。另一种情况是 CDN 侧的时间和你服务器时间差了十几分钟,签名算出来的过期时间在 CDN 看来已经过了。
解决:把 TTL 调到覆盖单次观看时长,比如 3600 秒;或者只对 manifest 签名,分片地址由 manifest 里的相对路径推导,不单独校验。服务器装个 NTP 同步时间,CDN 和源站时间差控制在 1 分钟内。查的时候看 Nginx 访问日志里 403 的请求是不是都带着同一个e=参数,是的话就是过期,不是的话再查签名算法对不对。
6. 二次开发:换源、加分类、加缓存的三步改法
拿到这套东西之后,最常见的三个改造方向,按投入产出比排一下:
| 改造点 | 改哪里 | 工作量 | 收益 |
|---|---|---|---|
| 换自有源 | SourceService::parseM3u+ 后台导入 | 半天 | 直接决定能不能用 |
| 加频道分类 | iptv_group表 + 列表接口 group 参数 | 一天 | 客户端体验提升明显 |
| 加接口缓存 | lists()里的 cache + Redis 配置 | 两小时 | 并发上来不崩 |
换源是最先要做的。后台一般有「导入 m3u」的入口,把文件传上去,走一遍解析入库。如果你的源格式不标准,比如描述行里没有tvg-name,只有,频道名,那 3.2 节的解析器已经兜住了。导入之后别急着在 APP 上看,先在数据库里SELECT COUNT(*) FROM iptv_channel WHERE status = 1,确认数量对得上,再清一次缓存cache('ch:list:0', null),不然看到的还是旧列表。
加分类要动两处:数据库加iptv_group表存分类名和排序,接口的lists($groupId)已经支持按分类筛,只要把group_id传对就行。APP 端在顶部加一个横向的 TabLayout,切换时带上group_id重新请求。这里有个小技巧,首次进页面请求一次全量(groupId=0),客户端自己按group_id分组渲染 Tab,切 Tab 不发请求,体验会顺很多,代价是首屏数据量稍大,几百条 JSON 也就几十 KB。
加缓存则是并发上来的救命稻草。默认的 file 缓存单机还行,多实例部署就得换 Redis。改config/cache.php的type为redis,填上地址和密码,lists()里那两行cache()不用动。验证方法很直接,压测工具打 100 并发,看数据库的SHOW PROCESSLIST里是不是只有零星几条查询,如果还是满屏的 SELECT,说明缓存没命中,八成是 key 拼错了或者缓存驱动没生效。
我自己的习惯是,每次改完接口先用 curl 打一遍,把返回的 JSON 存成文件,改完再打一遍做diff,字段少了、类型变了立刻能看出来——这比在 APP 上点半天靠谱得多。有一次就是靠这个 diff 发现加分类之后logo字段被漏选了,客户端台标全空,查了半小时以为是图片服务挂了。从那以后我每次动列表接口都强制走一遍 curl + diff,再上真机。希望帮到你。
本文还有配套的精品资源,点击获取