1. 电视端电子相册的真实需求与场景拆解
1.1 为什么电视需要专门的电子相册APP
家里那台电视,买回来头三个月新鲜,之后基本就是吃饭时开着当背景音。我折腾过不少方案,想把手机里的照片投到电视上看,结果发现手机投屏要么画质压缩得厉害,要么过一会儿就断连,老人根本不会操作。后来我干脆自己写了个安卓电子相册APP装在电视盒子上,插个U盘或者连局域网就能循环播放,用了大半年,稳定性比市面上大多数投屏方案强得多。
电视端电子相册和手机端完全是两码事。手机上看图,手指划来划去很自然;电视上你不可能拿遥控器一张张翻,核心诉求是自动轮播、大屏适配、长期稳定运行。而且电视盒子的硬件配置普遍偏低,很多还是安卓7甚至安卓5的系统,内存就1G,你拿手机端的图片加载库直接搬过去,分分钟OOM崩溃。这就是为什么我觉得有必要把踩过的坑整理出来,顺便把代码开源出来,让想自己动手的人少走弯路。
这个项目适合谁?如果你家里有闲置的电视盒子或者智能电视,想做一个专属的家庭相册轮播;或者你是安卓开发者,想找一个练手的TV端项目;再或者你只是好奇电视APP和手机APP到底差在哪,这篇内容都能给你答案。代码基于Android原生开发,兼容安卓5.0到安卓13,实测在小米盒子、当贝盒子、天猫魔盒上都能跑。
1.2 电视端开发的三个核心约束
做电视APP,第一件事就是忘掉手机开发的那套交互逻辑。电视端有三个硬约束你必须时刻记在脑子里。
遥控器是唯一的输入设备。没有触摸屏,没有多点触控,用户手里就一个方向键加确认返回。这意味着你的焦点控制必须极其清晰,哪个按钮被选中了要一目了然。我见过太多移植到电视上的APP,焦点框根本看不见,用户按了半天不知道光标在哪,直接卸载。
硬件性能天花板很低。电视盒子的CPU大多是晶晨S905或者瑞芯微RK系列,GPU是Mali-450这个级别的老古董。你加载一张4000x3000的照片,如果直接解码成Bitmap,内存瞬间飙到48MB,几张图下来就崩了。必须做采样压缩,而且要用inSampleSize精确计算,不能随便给个2就完事。
长时间运行不能挂。电子相册一开就是几个小时甚至全天,内存泄漏、ANR、后台被杀,任何一个问题都会让体验归零。我最初版本跑了两个小时就闪退,查了半天发现是图片加载库的缓存没设上限,把内存吃光了。后来改成手动管理Bitmap生命周期,连续跑72小时没出过问题。
2. 技术选型与整体架构设计
2.1 为什么不用现成的图片加载库
你可能会问,Glide、Picasso这些库这么成熟,直接拿来用不就行了?我一开始也是这么想的,结果在电视上翻车了。Glide默认的缓存策略是针对手机优化的,内存缓存占可用内存的25%,在1G内存的盒子上就是250MB,听着不多,但系统本身还要占掉一大半,实际留给APP的可能就100多MB。而且Glide的生命周期绑定是跟Activity走的,电视端我们通常只有一个Activity长期驻留,缓存永远不会释放。
我的方案是自己写一个轻量级的图片加载器,核心就三件事:异步解码、内存缓存、磁盘缓存。异步解码用ExecutorService固定两个线程,避免并发解码把CPU占满。内存缓存用LruCache,大小设为可用内存的1/8,并且设置硬上限20MB。磁盘缓存用DiskLruCache,存压缩后的缩略图,原图不缓存,因为电视上显示1080P就够了,原图纯属浪费。
注意:电视盒子的
Runtime.getRuntime().maxMemory()返回的值往往不准,有些厂商会虚标。我实测发现用ActivityManager.getMemoryClass()更可靠,它返回的是系统真正允许你用的堆大小。
2.2 整体架构分层
整个APP我分了三层,结构很简单,但每一层都有讲究。
数据层负责扫描图片。支持两种来源:本地U盘路径和局域网SMB共享。U盘扫描用File.listFiles()递归遍历,过滤出jpg、png、webp、heic格式。局域网这块我用了jcifs-ng库,虽然有点老,但胜在稳定,配置好IP和共享目录就能直接读。
逻辑层管理播放队列和轮播定时器。播放队列是一个LinkedList<ImageItem>,支持顺序播放和随机播放两种模式。轮播定时器用Handler.postDelayed()实现,间隔默认5秒,可在设置里调。这里有个细节:切换图片的时候要先取消上一个定时任务,否则快速切换会导致多个定时器叠加,图片疯了一样闪。
UI层就是一个ImageView加几个TextView,显示当前图片和进度信息。没有复杂的布局,因为电视端渲染性能有限,层级越少越好。我用的是FrameLayout做根布局,ImageView的scaleType设为fitCenter,保证图片完整显示不变形。
2.3 兼容性处理的坑
安卓碎片化在电视端尤其严重。我遇到过几个典型问题:安卓5.0上RecyclerView的焦点滚动有问题,安卓7.0上FileProvider的URI权限配置不一样,安卓9.0以上默认禁止明文HTTP请求。这些都得在代码里做版本判断。
还有一个特别隐蔽的坑:某些电视盒子的ImageView不支持webp格式,虽然系统版本是安卓9,但厂商裁剪了解码器。我的做法是在加载前先判断文件头,如果是webp就尝试解码,失败就跳过并记录日志,不让整个轮播卡住。
3. 核心功能实现与代码解析
3.1 图片扫描与采样压缩
扫描这块逻辑不复杂,但采样压缩是重点。电视屏幕一般是1920x1080,我们只需要解码到这个尺寸就够了。计算inSampleSize的公式是这样的:
public static int calculateInSampleSize( BitmapFactory.Options options, int reqWidth, int reqHeight) { final int height = options.outHeight; final int width = options.outWidth; int inSampleSize = 1; if (height > reqHeight || width > reqWidth) { final int halfHeight = height / 2; final int halfWidth = width / 2; while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { inSampleSize *= 2; } } return inSampleSize; }这个算法的逻辑是每次把采样率翻倍,直到解码后的尺寸刚好大于目标尺寸。比如一张4000x3000的图,目标1920x1080,第一次halfHeight=1500,halfWidth=2000,都大于目标,inSampleSize变成2;第二次halfHeight=750小于1080,停止。最终inSampleSize=2,解码出来2000x1500,内存占用从48MB降到12MB。
实操心得:
inSampleSize最好是2的幂次,虽然安卓官方说任意整数都行,但很多GPU对2的幂次有硬件优化,解码速度能快30%左右。
3.2 轮播定时器的正确写法
轮播看着简单,但写不好就是灾难。我最初的写法是在onPostExecute里直接postDelayed,结果图片加载失败的时候定时器就断了,整个轮播停在那里。正确的做法是把定时器独立出来,跟图片加载解耦。
private Runnable slideshowRunnable = new Runnable() { @Override public void run() { if (imageList.isEmpty()) return; currentIndex = (currentIndex + 1) % imageList.size(); loadImage(imageList.get(currentIndex)); handler.postDelayed(this, intervalMillis); } };关键点是handler.postDelayed(this, intervalMillis)放在loadImage之后,这样即使加载慢,下一次轮播也会等加载完再计时。另外handler要用主线程的Looper,别自己new一个,否则更新UI会崩。
3.3 焦点控制与遥控器适配
电视端最容易被忽视的就是焦点。我见过一个开源相册APP,在电视上按遥控器完全没反应,因为它的按钮根本没设focusable。正确的做法是给所有可交互控件加上android:focusable="true",并且自定义一个焦点框的drawable。
<selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:state_focused="true"> <shape android:shape="rectangle"> <stroke android:width="3dp" android:color="#FF4081"/> <corners android:radius="8dp"/> </shape> </item> <item> <shape android:shape="rectangle"> <solid android:color="#00000000"/> </shape> </item> </selector>焦点框的颜色要跟背景有强对比,我用的是粉色#FF4081,在深色背景上非常显眼。另外遥控器的按键响应要用onKeyDown而不是onClick,因为电视上确认键有时候不会触发onClick,特别是某些定制ROM。
3.4 局域网SMB图片读取
U盘总有拔来拔去的时候,局域网共享才是长期方案。我用jcifs-ng连接SMB共享,配置很简单:
CIFSContext context = new BaseContext(new PropertyConfiguration()); context.getTransportPool().setMaxPoolSize(2); SmbFile smbFile = new SmbFile("smb://192.168.1.100/photos/", context); SmbFile[] files = smbFile.listFiles();这里有个坑:jcifs-ng默认的缓冲区大小是64KB,读大图会很慢。我改成1MB之后,加载速度从3秒降到0.8秒。另外SMB连接要设超时,不然网络断了APP会卡死。context.getTransportPool().setMaxPoolSize(2)限制并发连接数,避免把路由器搞挂。
注意:安卓9以上默认禁止明文流量,SMB走的是445端口,需要在
AndroidManifest.xml里加android:usesCleartextTraffic="true",或者配置network_security_config只允许特定IP。
4. 常见问题排查与避坑实录
4.1 内存泄漏与OOM排查
电视端OOM是头号杀手。我遇到过一次,APP跑了一个小时就崩,日志显示OutOfMemoryError。用Android Studio的Profiler抓了一下,发现Bitmap对象一直在涨,GC根本回收不掉。原因是我的LruCache的sizeOf方法返回的是图片数量而不是字节数,导致缓存上限形同虚设。
@Override protected int sizeOf(String key, Bitmap bitmap) { return bitmap.getByteCount() / 1024; }改成返回KB数之后,缓存上限20MB就真正生效了。另外记得在onDestroy里调用lruCache.evictAll()和diskLruCache.close(),不然退出APP后内存和文件句柄都不会释放。
4.2 图片显示黑屏或花屏
有些图片在电视上显示是黑的,但在手机上看正常。这通常是色彩空间的问题。电视盒子对RGB_565的支持比ARGB_8888好,但RGB_565没有透明通道,png的透明区域会变成黑色。我的做法是判断图片格式:jpg用RGB_565省内存,png用ARGB_8888保透明。
options.inPreferredConfig = isPng ? Bitmap.Config.ARGB_8888 : Bitmap.Config.RGB_565;花屏一般是解码器的问题,某些盒子的硬件解码器对渐进式jpg支持不好。解决办法是用软件解码,在BitmapFactory.Options里设inJustDecodeBounds = false,然后手动调用BitmapFactory.decodeStream,绕过硬件加速。
4.3 遥控器按键无响应
这个问题我排查了两天,最后发现是Activity的dispatchKeyEvent被拦截了。电视盒子的遥控器按键有时候会先被系统拦截,特别是主页键和菜单键。解决办法是在Activity里重写onKeyDown,并且返回true表示已处理。
@Override public boolean onKeyDown(int keyCode, KeyEvent event) { switch (keyCode) { case KeyEvent.KEYCODE_DPAD_LEFT: prevImage(); return true; case KeyEvent.KEYCODE_DPAD_RIGHT: nextImage(); return true; case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: toggleSlideshow(); return true; } return super.onKeyDown(keyCode, event); }另外有些盒子的遥控器按键码不一样,最好在onKeyDown里把keyCode打印出来,实测一下再写逻辑。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| APP启动闪退 | 权限未申请 | 安卓6以上动态申请READ_EXTERNAL_STORAGE |
| 图片加载慢 | 未做采样压缩 | 计算inSampleSize,目标尺寸设为屏幕分辨率 |
| 轮播卡住不动 | 定时器被中断 | 用独立Handler,加载失败也要继续postDelayed |
| 内存持续增长 | 缓存未设上限 | LruCache的sizeOf返回字节数,设硬上限 |
| 局域网读图失败 | 明文流量被禁 | 配置network_security_config或设usesCleartextTraffic |
| 遥控器无响应 | 焦点未设置 | 所有控件加focusable,自定义焦点框 |
| 图片显示变形 | scaleType不对 | 用fitCenter保持比例,不要用fitXY |
| 长时间运行崩溃 | 内存泄漏 | onDestroy里释放缓存和线程池 |
5. 开源代码结构与二次开发建议
5.1 代码目录结构
开源代码我放在了GitHub上,结构很清晰,方便你直接拿来改。
TVPhotoAlbum/ ├── app/ │ ├── src/main/java/com/example/tvphotoalbum/ │ │ ├── MainActivity.java // 主界面,轮播逻辑 │ │ ├── ImageLoader.java // 图片加载器 │ │ ├── ImageScanner.java // 图片扫描 │ │ ├── SmbClient.java // 局域网SMB │ │ └── SettingsActivity.java // 设置页 │ ├── src/main/res/ │ │ ├── layout/ // 布局文件 │ │ ├── drawable/ // 焦点框等资源 │ │ └── values/ // 字符串和样式 │ └── AndroidManifest.xml └── build.gradle核心类就五个,MainActivity大概300行,ImageLoader200行,其他都是辅助类。代码里注释写得很详细,关键地方都标了为什么这么写。
5.2 二次开发可以怎么改
如果你想在这个基础上做定制,有几个方向很容易上手。
加背景音乐。在MainActivity里加一个MediaPlayer,循环播放本地音乐文件。注意音频焦点要处理好,遥控器按静音键的时候要能暂停。
加天气显示。在右上角加一个TextView,定时请求天气API更新。这个需要网络权限,而且要注意API的调用频率限制。
加人脸识别自动分类。这个稍微复杂点,可以用Google的ML Kit,但电视盒子性能有限,建议只做简单的人脸检测,别做识别。
改成视频轮播。把ImageView换成VideoView,扫描的时候过滤mp4文件。注意视频解码对硬件要求高,老盒子可能播不动1080P。
实操心得:改代码之前先用Git打个分支,电视端调试不像手机那么方便,改崩了回滚很麻烦。另外每次改完要在至少两个不同品牌的盒子上测,兼容性差异比你想象的大。
5.3 上架应用市场的注意事项
如果你想把这个APP上架到应用市场,有几个坑得提前知道。电视应用市场对APP的界面规范有要求,比如必须有明确的焦点框、必须支持遥控器操作、不能有触摸屏专属的交互。另外隐私政策必须写清楚,因为你要读取存储权限。我建议先上架当贝市场和沙发管家,这两个对个人开发者比较友好,审核也快。
代码里我留了一个about页面,你可以改成自己的信息。开源协议用的是MIT,随便改随便用,不用署名,但出了事别找我。
最后说个真实体会:电视端开发最考验耐心,因为调试成本高,改一行代码要重新打包、安装、用遥控器测半天。但一旦跑通,那种成就感是手机开发比不了的。我家那台老盒子现在每天自动轮播孩子的照片,老人再也不用打电话问我怎么投屏了。代码你拿去改,遇到问题可以在仓库里提issue,我看到会回。