简介:这是一份面向苹果CMS影视站开发者与二次开发者的安卓原生APP+后端源码包,由最新优化版前端App与Feiapp后端组成,用于快速搭建对接苹果CMS的移动端视频应用。包内含后端PHP接口、前端App工程资源、主题样式及数据库相关文件,共843个文件,其中以438个JS脚本、100个CSS样式、60个PHP服务端文件、22个JSON配置、73个PNG和47张JPG图片为主,整体压缩包约14.92MB,结构清晰,便于按模块查看。部署时需注意PHP版本建议7.0-7.4,且后端需与苹果CMS使用同一数据库以读取视频与分类信息;附录说明及SQL文件可辅助完成配置。目前已有103人学习/下载,适合具备基础PHP和Android开发经验的用户用于本地部署、接口调试或在此基础上做功能定制,同时应遵守资源使用声明,勿用于非法用途。
1. 安卓原生对接苹果cms App后端:为什么绕了一圈又回到原生
做视频类App的人基本都经历过一种折返:先用WebView套壳,再用uniapp或Flutter打包,最后回到安卓原生对接苹果cms App后端。原因很直白:苹果cms的接口本质是一堆JSON和m3u8地址,原生App拿起来最快,播放器和列表渲染都不需要中间层,资源占用比套壳方案低一个量级。这篇文章不聊框架口水仗,直接落地:怎么读懂苹果cms的接口返回、怎么搭出一个能跑的最小原生工程、播放地址怎么喂给播放器、以及我踩过的几个会让你线上翻车的问题。特别是那些把苹果cms二开成采集站、做了泛目录的站点,API输出经常被改动过,原生App反而比H5页面更容易绕开这些干扰。这份笔记适合已经写过一点Android代码、想自己维护一个视频App的人。
2. 读懂苹果cms后端的接口约定:三个动作、一套字段和播放地址解析
苹果cms本身就是PHP写的站点后端,App对接时不需要再单独起一套后端服务,直接消费它暴露出的JSON接口即可。常见版本默认在api.php/provide/vod/路径下开放接口,动作由ac参数控制。用法不复杂,但对第一次对接的人来说,真正的坑不在请求,而在返回数据里那一堆$$$和###分隔符上。
2.1 接口动作清单与返回字段:videolist、detail、play怎么用
我先给一张最简参数表,平时做App对接,90%的场景只用到这三组动作:
| 动作 | 用途 | 常用参数 | 返回内容 |
|---|---|---|---|
| videolist | 列表页、搜索页、首页推荐 | pg页码、t分类ID、wd关键词、h时间范围 | 列表数组 |
| detail | 视频详情页 | ids视频ID | 单个或多个视频的完整字段 |
| play | 播放地址(部分二开版本单独提供) | ids视频ID | 播放地址集合 |
顶层返回结构一般是{"code":1,"msg":"数据列表","page":1,"pagecount":10,"total":100,"list":[...]}。判断成功不要看msg,只看code是否等于1。有些站点在异常时会返回code:0,或者干脆吐出一个HTML错误页,后者经常被Retrofit当成JSON解析异常抛出来。对接时要先对ResponseBody做健壮性判断,把非JSON内容单独打日志,否则线上排查会像面对一个黑匣子。
list里的视频字段,App端最常用的是下面这些:
vod_id:视频唯一ID,详情页和播放地址请求都要拿它做ids参数。vod_name:标题,直接显示在列表卡片上。vod_pic:封面图,给Glide加载用。vod_remarks:备注,常见值是“HD”“完结”“1080P”,很多站点把它当作封面角标文字。vod_class:视频分类名的冗余字段,可以做列表页的筛选标签。vod_play_from:播放源名称列表。vod_play_url:播放地址组。
有一个细节新手容易忽略:vod_time字段表示更新入库时间,做“最近更新”排序时可以用它,但不同站点对这个字段的时区处理不一致,不能直接拿来和服务器时间比大小,只在客户端展示时间文案就好。
分类树在苹果cms的API里并不是每次都能单独拿到。有的二开版本会提供分类接口,有的只出现在站点网页导航里。我现在的习惯是:先请求一页ac=videolist&t=0,把返回list里的type_id和type_name去重,生成一份“有内容的分类映射”。这样既省请求次数,又避免展示空分类。缺点是没有数据的空栏目不会出现,如果产品经理要求显示完整栏目树,再去站点后台拿全量配置。
2.2 播放地址解析:三种分隔符的处理和集数切换
这是苹果cms对接里最反直觉的部分。vod_play_from可能长这样:m3u8$$$svip,对应的vod_play_url长这样:
第01集$https://cdn.example.com/a.m3u8#第02集$https://cdn.example.com/b.m3u8$$$第01集$https://svip.example.com/1.m3u8#第02集$https://svip.example.com/2.m3u8
分成三层:
$$$:把不同播放来源的集数列表隔开。###:把同一个来源里的每一集隔开。$:把每个条目的“集数名称”和“真实播放URL”隔开。
Kotlin解析函数:
data class Episode(val name: String, val url: String) fun parsePlayUrls(playFrom: String, playUrl: String): Map<String, List<Episode>> { val fromList = playFrom.split("\\$\\$\\$".toRegex()) val urlGroups = playUrl.split("\\$\\$\\$".toRegex()) if (fromList.size != urlGroups.size) { // 采集站经常出现来源数对不上的情况,兜底取第一个来源 return if (urlGroups.isNotEmpty()) { mapOf("default" to parseEpisodes(urlGroups.first())) } else { emptyMap() } } val result = LinkedHashMap<String, List<Episode>>() for (i in fromList.indices) { result[fromList[i]] = parseEpisodes(urlGroups[i]) } return result } fun parseEpisodes(group: String): List<Episode> { return group.split("###").mapNotNull { item -> val parts = item.split("$") if (parts.size < 2) null else Episode(name = parts[0], url = parts.subList(1, parts.size).joinToString("$")) } }逻辑说明:外层按来源拆分后,每个来源内部再按集数拆分。拆分$之后不要简单只取parts[1],因为播放地址携带的query参数里可能也包含$字符,比如https://a.com/play?token=abc$def。用subList(1, parts.size)把所有剩余段重新拼回去,才能拿到完整URL。
这里还要注意播放源的名称并不一定规范。比如vod_play_from里出现http或spare这种无法翻译成中文的名称,不要在前端硬编码“播放源1/播放源2”,直接按接口给的字符串展示,省得站点调整采集来源后名称对不上。
集数切换的做法就是重新解析vod_play_url,然后按索引取出对应Episode。需要做的一个额外工作是:同一个视频切换播放源时,把来源名称、当前集数索引和播放进度记录下来,下次进入详情页能恢复进度。这个记录推荐放在本地数据库或DataStore里,不要放在全局变量,因为App进程被杀后记录就没了。
2.3 容易被忽略的边界字段:h参数、pagecount和播放源命名
h参数的作用是按更新时间过滤,比如h=24表示最近24小时更新的内容,这个参数在做“今日上新”栏目时很实用。要注意的是,部分站点的采集任务不是连续执行的,h过滤后可能返回空列表,前端得有对应的空状态UI,而不是显示一个转圈然后卡死。
分页字段比很多人想象的要敏感。接口返回里的pagecount是总页数,total是总条数。做“加载更多”时,正确的终止条件是pg >= pagecount,而不是用pg * 每页条数 >= total。因为苹果cms后台可以配置每页输出条数,而采集来源的total可能包含下架视频,算出来的页数会偏大,导致App在最后一页反复请求空数据。
还有一点:原生App没有浏览器那种跨域限制,不用处理CORS,真正要留意的是返回字段的差异。不同二开版本可能把vod_play_url改名为vod_play或直接塞进vod_content里,解析层做一个字段映射表,适配成本比反复改UI低得多。
3. 搭出能跑的安卓原生工程:Retrofit网络层、列表渲染和播放器
既然标题里强调了“优化版”,网络层就不建议直接用HttpURLConnection裸写。它没有连接池,并发场景下每次请求都重复握手,也没有拦截器机制,后面想统一加UA或缓存都要自己写。常见且可靠的做法是Retrofit加OkHttp加Gson组合,Kotlin工程里再配合协程。
3.1 依赖选型与OkHttp配置:超时、UA和日志拦截器
先看依赖配置:
// app/build.gradle.kts dependencies { implementation("com.squareup.retrofit2:retrofit:2.9.0") implementation("com.squareup.retrofit2:converter-gson:2.9.0") implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") implementation("com.github.bumptech.glide:glide:4.16.0") implementation("androidx.media3:media3-exoplayer:1.3.1") implementation("androidx.media3:media3-exoplayer-hls:1.3.1") }逻辑说明:converter-gson负责把JSON转成数据类,media3-exoplayer是当前主流的播放器库,后面会用它播放m3u8,Glide负责封面图加载。如果项目还在用Java,把协程部分换成Callback或RxJava就行,依赖三件套保持不变。
OkHttp实例的配置:
val okHttpClient = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS) .retryOnConnectionFailure(true) .addInterceptor { chain -> val request = chain.request().newBuilder() .header("User-Agent", "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36") .header("Accept", "application/json") .build() chain.proceed(request) } .addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }) .build() val retrofit = Retrofit.Builder() .baseUrl("https://api.example.com/") // 替换成你自己的苹果cms站点域名 .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build()逻辑说明:UA头必须加,因为部分苹果cms站点在防盗链设置里会检查UA,缺失时直接返回403或空数据。日志拦截器在开发阶段用BASIC级别,避免返回体太大把logcat刷爆。
参数说明:readTimeout我习惯给15秒而不是默认的10秒,因为采集站点在凌晨做定时采集时接口响应会明显变慢,10秒经常触发超时重试,重试反而加剧站点压力。如果站点本身做了CDN加速,可以缩到8秒到10秒。
3.2 数据类与列表绑定:可空字段处理、DiffUtil和Glide
定义数据类时需要注意,苹果cms返回的JSON里有些字段可能是null而不是缺失。Gson默认不会报错,但如果你把Int类型定义成非空,遇到null会直接抛JsonDataException。稳妥做法是全部用可空类型,页面显示时再给默认值。
data class VodResponse( val code: Int? = 0, val msg: String? = "", val page: Int? = 0, val pagecount: Int? = 0, val total: Int? = 0, val list: List<VodItem>? = emptyList() ) data class VodItem( val vod_id: Int? = 0, val vod_name: String? = "", val vod_pic: String? = "", val vod_remarks: String? = "", val vod_class: String? = "", val vod_play_from: String? = "", val vod_play_url: String? = "" ) interface CmsApi { @GET("api.php/provide/vod/") suspend fun getVideoList( @Query("ac") ac: String = "videolist", @Query("pg") page: Int, @Query("t") typeId: Int? = null, @Query("wd") keyword: String? = null ): VodResponse }逻辑说明:接口路径按api.php/provide/vod/写,baseUrl填站点域名。不同站点的苹果cms版本路由有差异,如果后端改过伪静态,直接在@GET注解里调整即可,不用动业务层。这和做前后端分离项目实战时的思路一致,接口路径的变化应该限制在网络层一个文件里。
列表页绑定:
class VodAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val item = currentList[position] holder.name.text = item.vod_name ?: "暂无标题" holder.remark.text = item.vod_remarks ?: "更新中" Glide.with(holder.itemView.context) .load(item.vod_pic) .placeholder(R.drawable.img_placeholder) .error(R.drawable.img_error) .override(300, 400) .centerCrop() .into(holder.image) } }参数说明:placeholder和error两张占位图必须准备。苹果cms采集过程中很多封面链接是失效的,没有占位图就会出现大片空白区域,用户观感很差。.override(300, 400)控制图片缓存尺寸,避免把站点原图整张缓存到本地,低端机内存压力能小不少。
3.3 播放页接入:用Media3播放m3u8并处理防盗链与切集
m3u8播放地址往往带防盗链要求,直接丢给播放器会出现“刚播就停”或黑屏。常见处理方式是在MediaSource上附加请求头:
val defaultHttpDataSource = DefaultHttpDataSource.Factory() .setUserAgent("Mozilla/5.0 (Linux; Android 10)") .setDefaultRequestProperties(mapOf( "Referer" to "https://api.example.com/", "Connection" to "keep-alive" )) val mediaItem = MediaItem.Builder() .setUri(videoUrl) .setMimeType(MimeTypes.APPLICATION_M3U8) .build() val player = ExoPlayer.Builder(context) .setMediaSourceFactory(DefaultMediaSourceFactory(defaultHttpDataSource)) .build() player.setMediaItem(mediaItem) player.prepare() player.playWhenReady = true逻辑说明:苹果cms站点在视频来源上大多使用采集的第三方m3u8,部分来源会校验Referer,必须把站点首页域名放进Referer头。如果同时对接了多个播放源,切换来源时要重新创建MediaItem并调用player.setMediaItem()。
集数选择在苹果cms里本质是对vod_play_url重新解析,然后按索引取出对应url。切换集数时,先调用player.stop(),再setMediaItem重新prepare,避免播放器还停留在上一集的缓冲状态。这里也建议把播放进度回调监听做上,一方面用于进度恢复,另一方面能及时释放播放器资源。
4. 对接苹果cms的高频踩坑记录:现象、原因和处理方式
苹果cms站点因为二开程度不同,客户端踩坑的方式也五花八门。下面五条是我对接多个站点后沉淀下来的高频问题,基本覆盖了从列表到播放的完整链路。
4.1 列表接口code=1但list为空:把t参数换成叶子分类ID
现象:首页列表接口返回正常,code=1,pagecount也正常,但list是空数组,客户端往下游传数据时直接崩。
原因:请求时t参数传了父级分类ID。苹果cms的内容挂在最末级分类下,父分类下没有直接视频。部分站点后台配置的多级分类树,父栏目只是个分组容器。
解决:先拿type_id和type_name去重得到映射,再让用户进入叶子分类后请求列表。如果首页要直接展示内容,t参数传0(全部分类)而不是某个栏目ID。另外,二开站点可能把分类ID和栏目ID分开配置,需要从站点后台确认哪种ID真正被接口接受。
4.2 播放黑屏但浏览器能放:给播放器补上防盗链请求头
现象:同一个m3u8地址,浏览器能正常播放,App里进入播放页后黑屏,Logcat只看到Player状态切换,没有任何异常堆栈。
原因:m3u8源站校验了Referer和Origin,浏览器请求自带完整页面环境,播放器请求头里只有默认UA。
解决:在做播放请求前,先用命令行在电脑上验证一下:curl -I -H "Referer: 站点首页域名" 播放地址,返回200说明防盗链只查Referer,把对应域名加进DefaultHttpDataSource.Factory.setDefaultRequestProperties即可。如果返回403,再检查是否需要带token或时间戳签名,那就要在业务层先从接口拿到完整播放地址再交给播放器。
4.3 中文乱码显示成问号:在后端改UTF-8或在客户端拦截转码
现象:vod_name显示成一串æ··或?????,列表和详情全是乱码。
原因:苹果cms输出JSON是UTF-8,但部分老站点在模板层做了GBK转码,或者数据库连接字符集配置不一致,导致响应头charset缺失或被写成ISO-8859-1。Gson拿到字节流后按默认字符集解析,自然就乱了。
解决:先在Postman看响应头是否带charset=utf-8。如果缺失,可以在OkHttp层加一个转码拦截器:
class CharsetInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed(chain.request()) val body = response.body ?: return response val raw = body.bytes() val text = try { String(raw, Charsets.UTF_8) } catch (e: Exception) { String(raw, Charset.forName("GBK")) } return response.newBuilder() .body(text.toResponseBody("application/json; charset=utf-8".toMediaType())) .build() } }逻辑说明:这种方案是兜底,不是根治。真正的处理应该是在苹果cms后台把数据库和输出统一成UTF-8。如果你只是对接别人的站点、没法改后端,再用拦截器处理。
4.4 封面图大面积加载失败:相对路径和外站防盗链缺一不可
现象:首页十六个卡片,七八个全是灰色占位图,个别图片偶尔能刷出来。
原因:苹果cms采集入库的vod_pic有两种情况,一是相对路径,比如/upload/vod/2023/123.jpg;二是外站绝对地址,但外站做了图片防盗链。Glide能加载绝对URL,相对路径不能直接load。
解决:加载前做一个前缀拼接判断:
fun resolveCover(url: String?, baseUrl: String): String { if (url.isNullOrEmpty()) return "" return if (url.startsWith("http://") || url.startsWith("https://")) { url } else { baseUrl.trimEnd('/') + "/" + url.trimStart('/') } }外站防盗链图片在客户端处理性价比很低,直接走Glide的.error()占位图,不要反复重试。这类问题通常会在采集一段时间后自行恢复,因为采集源更新了图片地址。
4.5 低端机列表滑动掉帧甚至闪退:从图片缓存和播放器释放入手
现象:几百个条目的列表在低内存机型上滑动明显卡顿,偶尔发生OutOfMemory崩溃。
原因:苹果cms的封面图原始分辨率很高,没有限制尺寸就整图加载,内存里积压大量Bitmap;加上RecyclerView没有做DiffUtil,快速滑动时每个条目都重新创建。
解决:Glide统一加.override(300, 400),列表页用DiffUtil配合setHasStableIds(true),播放页在onStop里调用player.release(),不要让播放器实例驻留在Activity里。这几点做完,低端机的掉帧问题基本能消除大半。
5. 参数调优与多站点适配:超时、分页、站点配置的落地细节
接口通了、页面能播了,剩下的工作就是让它在真实网络环境下跑得稳。这一章的参数和配置,就是在“能跑”和“能上线”之间补上差距。
5.1 OkHttp的五个参数怎么设:连接、读取和并发上限
很多开发直接用OkHttp默认值对接苹果cms,默认值在普通网站场景够用,但在视频站点上不够。苹果cms接口在后端采集时响应经常飙到20秒以上,图片又是独立静态资源,连接速度和接口完全不一样。我推荐的设置:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| connectTimeout | 8s | 国内站点直连足够,走CDN可以压到5s |
| readTimeout | 15s | 采集高峰时段接口可能很慢,给足余量 |
| maxRequestsPerHost | 5 | 防止客户端在滑动列表时把站点打崩 |
| maxRequests | 20 | 整体并发控制,多站点聚合时需要调大 |
| retryOnConnectionFailure | true | 移动网络切换时自动重试 |
maxRequestsPerHost是很多原生App没动过的参数。如果你做的是聚合多个站点的App,首页会同时请求多个域名,这个值建议单独拆两个OkHttp实例:一个给接口请求,一个给图片请求。图片那一路并发可以放松,接口那一路收严,既保证列表加载速度,又不至于把采集站的PHP进程打满。
5.2 分页与缓存策略:pagecount是重置页码的唯一依据
苹果cms的列表接口本身返回page、pagecount、total。App做“加载更多”时,不要把total拿来计算还剩几页,因为total可能包含很多权重低的采集内容,而pagecount才是实际页数。
我的落地做法:
- 每次请求携带
pg参数,与接口返回的page对比,pg >= pagecount时停止加载更多。 - 列表缓存建议用本地数据库或直接存JSON,每次请求成功后整体重写缓存,不要增量追加,防止站点下架视频后App还展示旧数据。
- 搜索页的
wd关键词清空后,回到列表页必须重置页码,否则你会拿搜索结果的页码去请求列表接口,返回错乱。
缓存过期时间我习惯设为6小时,既保证内容新鲜度,又避免每次进入App都全量请求。热点站点接口压力本来就大,能少打一次是一次。
5.3 多站点切换:SiteConfig管理域名和Referer的对应关系
如果你做的是聚合多个苹果cms站点的App,域名和Referer不能散落在代码里。用一份配置类统一管理,运行时切换站点就很方便。
data class SiteConfig( val siteName: String, val apiBaseUrl: String, val refererUrl: String ) { companion object { val defaultSite = SiteConfig( siteName = "默认站点", apiBaseUrl = "https://api.example.com/", refererUrl = "https://api.example.com/" ) } }逻辑说明:每个苹果cms站点的API格式基本一致,但字段覆盖范围不同。SiteConfig把API基地址和Referer绑定到一起,切换站点后网络层用新的baseUrl重建Retrofit实例,播放层用新的refererUrl重建播放器的请求头。
注意一个细节:切换站点后,本地缓存必须清掉。否则拿A站点的封面图去匹配B站点的视频ID,详情页会张冠李戴,用户会以为App出了灵异事件。
如果你的站点要求传token或sign签名,可以在SiteConfig里增加一个extraParams字段,在OkHttp拦截器里统一拼接到Query,而不是在每个接口定义里重复写。签名过期时会有一个集中的地方排查,比散落在各个请求里好处理得多。
6. 验证对接链路:抓包、日志拦截器和播放器错误码三件套
这类对接项目最大的风险不在代码,而在苹果cms站点返回的数据永远让你意料不到。我现在接新站点的流程是:先抓包再写代码,写完代码再看播放器错误码。三步固定成流程后,线上“用户说打不开”的工单至少能少一半。
6.1 从日志比对到播放错误码,开发期必须做完整链路验证
第一步,在OkHttp日志拦截器里把请求URL和响应头完整打出来,直接和浏览器地址栏里的地址做对比。遇到URL里的中文关键字变成一串%E4%BD%A0,说明客户端做了URL编码而后端没解。这时候不要急着改代码,先和后端确认他们接收参数时是否统一解码,责任边界清楚了再动手。
第二步,播放地址验证建议在电脑上先用桌面播放器打开。m3u8链接在手机上失败但在电脑播放器能放,基本锁定是防盗链头或UA问题;如果电脑播放器也打不开,说明地址内容本身有问题,可能是采集时就已经失效了。
第三步,记录播放器错误码,在测试包里弹窗显示出来。ExoPlayer的错误码对问题定位非常直观:
| 错误码 | 含义 | 优先排查方向 |
|---|---|---|
| ERROR_CODE_IO | 网络读取出错 | Referer、UA、m3u8链接是否过期 |
| ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED | 容器格式不兼容 | 换成桌面播放器看真实封装格式 |
| ERROR_CODE_PLAYER_DISALLOWED | 播放器策略拒绝 | 检查是否启用DRM或播放限制 |
接口兼容层我建议写得宽松一点,解析失败不要直接抛异常,把原始字符串原样交给播放器兜底,往往能正常播出来。这套做法替我挡过很多次“站点换了播放器插件导致App播放翻车”的问题,希望对你有用,也希望你的接入流程一次跑通。
本文还有配套的精品资源,点击获取