用Android+Kotlin+Compose打造轻量股票行情应用:从免费接口到实时刷新
简介基于Android平台的股票行情小软件完整工程包数据源采用新浪股票接口适合Android初学者或对证券数据可视化感兴趣的开发者学习参考。项目实现了自选股的添加与删除支持输入股票代码和简称并覆盖三大股票的实时行情同时提供个股日K、周K、月K等历史K线展示以及盘口交易数据功能脉络清晰代码结构紧凑。资源包共116个文件大小约3.79MB以Java源码、编译生成的class文件、Android界面布局XML、界面图标PNG以及第三方JAR依赖为主另附可直接安装的APK便于对照运行和验证功能。目前已有848人学习下载可作为课程设计、毕业设计或入门实战的参考项目。通过阅读源码可掌握网络数据请求解析、列表增删交互、K线绘制及多界面跳转等常见Android开发要点具备较强的动手演练价值。 我印象很深有一次在地铁上打开手机想看一眼自选股结果被某炒股App的开屏广告、持仓弹窗、资讯红点轮番轰炸等真正看到股价已经过去半分钟了。我就想自己用Android做一个简单的股票行情小软件打开就是列表刷新就是价格不搞社区不看资讯干净利落。这个想法落地之后用了大半年启动快、不卡顿、没广告成了我手机里打开频率最高的行情工具。这篇就聊聊我怎么做出来的包括数据源选择、UI刷新方案、轮询省电的那些事以及真机联调时踩过的坑。如果你也是Android开发者想给自己或者身边人写一个够用且不复杂的行情软件这篇应该能帮你少走不少弯路。1. 动手之前先定边界什么才算简单的行情软件1.1 我把产品需求收敛成了三件事市面上的行情App功能很多但绝大多数人日常就三件事看自选列表、看实时价格、看涨跌幅。所以这个软件我一开始就划定了功能红线——只有三个页面状态自选列表页、行情详情页只展示价格相关字段、搜索添加页。不做K线、不做分时图、不接资讯流、不做模拟交易。简单在不同人眼里定义不同但对软件来说少即是多。每多一个功能就多一条刷新链路、多一类异常处理。一个只做行情的App其实真正要解决的核心问题只有两个数据从哪来、界面怎么刷。把这两件事做好这个软件就立住了。我给自己定的验收标准是从点击图标到看见行情不超过2秒自选列表滑动不掉帧切到后台就不耗电打开App看到的永远是最近一次成功的行情数据。1.2 技术选型原生Android Kotlin Jetpack Compose技术栈我选的是原生Kotlin Jetpack Compose维护起来顺手。之前有人问我为什么不用Flutter或者WebView套壳。我的理由很简单行情类应用对刷新流畅度和内存占用敏感原生方案在列表滚动和局部刷新上更有底气同时这个项目不涉及多端复用、不需要复杂原生插件原生开发反而是最省事的路径。Compose在这个场景下有个天然优势数据变化驱动UI重组价格更新直接更新到某个Text组件不需要手动notifyDataSetChanged。列表用LazyColumn配稳定key局部更新非常丝滑。版本方面我的配置是minSdk 24、targetSdk 34兼容绝大多数市面上还在用的Android手机。JDK用的17Android Studio版本建议至少保持Hedgehog之后的版本对Compose编译支持更好。如果你熟悉XML RecyclerView那一套同样可以完成这个项目核心思路大同小异只不过UI更新的代码量会多一些。我这里直接用Compose讲因为它确实是目前新项目的默认选择。2. 行情数据从哪来免费接口的选择和解析细节2.1 免费A股行情接口做股票行情软件第一道坎就是数据源。正规的券商行情接口需要License个人开发者基本拿不到第三方聚合API大多收费免费额度也很寒酸。实际上最靠谱的免费方案是新浪和腾讯的HTTP行情接口它们本来是为自家网页版服务的返回的是纯文本或简单格式直接用就行。我用得最多的是新浪接口请求格式https://hq.sinajs.cn/listsh600519,sz000001,sh000001这个接口支持一次请求多个股票代码用逗号分隔返回内容是一个JS变量赋值每一行代表一只股票的行情字段。返回样例var hq_str_sh600519贵州茅台,1700.000,1690.000,1720.000,1735.000,1688.000,1719.000,1720.500,2023-12-01,15:00:00,00,;腾讯接口格式类似https://qt.gtimg.cn/qsh600519两个接口返回的都是GBK编码的中文文本这一点后面专门讲坑。2.2 各类字段的含义和解析逻辑新浪接口返回的字符串以逗号分隔关键字段索引如下索引含义示例值0股票名称贵州茅台1今开价1700.0002昨收价1690.0003当前价1720.0004今日最高价1735.0005今日最低价1688.0006买一报价1719.0007卖一报价1720.5008日期2023-12-019时间15:00:00涨跌幅需要自己算(当前价 - 昨收价) / 昨收价 * 100。所以数据模型里我存的是昨收和当前价UI层统一计算涨跌幅这样不管来源是哪家接口都能算出正确结果。2.3 请求时的两个关键配置新浪接口有个防盗链限制直接不带Referer请求会返回403或者提示信息。所以OkHttp请求时必须带一个浏览器的Referer头val request Request.Builder() .url(https://hq.sinajs.cn/listsh600519) .header(Referer, https://finance.sina.com.cn) .header(User-Agent, Mozilla/5.0 (Windows NT 10.0; Win64; x64)) .build()另一个关键点是编码。OkHttp默认用UTF-8解析响应体但接口返回的是GBK直接用response.body?.string()会乱码。正确做法是先拿到原始字节流再手动指定GBK解码val bytes response.body?.bytes() ?: return val text String(bytes, Charset.forName(GBK))这个细节不处理你看到的股票名称就是一堆乱码。注意新浪接口的个股代码前必须带市场前缀sh代表上海sz代表深圳。不带前缀或前缀错了会返回空数据。指数也一样上证指数是sh000001深证成指是sz399001。3. UI层怎么做列表刷新不闪烁、不卡顿的方案3.1 数据模型和状态设计行情数据每秒钟都在变但UI不能整个列表全部重建否则滑动时会有明显的闪烁和卡顿。我用Compose的状态管理解决了这个问题。先定义数据模型data class StockQuote( val code: String, val name: String, val currentPrice: Double, val preClosePrice: Double, val openPrice: Double, val highPrice: Double, val lowPrice: Double ) { val changePercent: Double get() if (preClosePrice 0) { (currentPrice - preClosePrice) / preClosePrice * 100 } else 0.0 }页面里维护一个自选股列表状态var quoteList by remember { mutableStateOfListStockQuote(emptyList()) }请求完成后更新这个状态Compose只会在数据变化时重组依赖了数据的UI组件。关键是LazyColumn里每一项要设置稳定的key用股票代码做keyLazyColumn { items(quoteList, key { it.code }) { quote - StockListItem(quote quote) } }这样当某一只股票价格变化时只有那一行会被重组其他行不受影响。3.2 颜色变红变绿的规则和防闪烁处理A股的涨跌颜色规则是红涨绿跌、平盘灰色跟美股刚好相反。价格变色逻辑我抽成了一个独立函数Composable fun StockListItem(quote: StockQuote) { val color when { quote.currentPrice quote.preClosePrice - Color(0xFFE53935) quote.currentPrice quote.preClosePrice - Color(0xFF43A047) else - Color(0xFF757575) } Text( text String.format(%.2f, quote.currentPrice), color color, style MaterialTheme.typography.titleMedium ) }这里有个细节值得注意红色和绿色必须在深色和浅色背景下都清晰可读。我最初用的纯红Color.Red和纯绿Color.Green在深色模式下对比度差、看着费劲后来换成了Material Design的红色和绿色才能看清楚。还有一个体验优化是价格变化提示。A股股票价格每次跳动至少0.01元如果只是颜色变了眼睛不太容易捕捉到变化。我加了一个轻量的高亮动画价格变动时数字背景短暂闪一下颜色再变回透明。这个效果在Compose里用Animatable就能实现val background by animateColorAsState( targetValue if (isChanged) flashColor else Color.Transparent, animationSpec tween(durationMillis 400) )实测这个效果对扫一眼就知道哪只股票在动的帮助非常大而且实现成本很低。3.3 格式化显示的坑股票价格显示成几位小数不同价格区间规则不一样。我的做法简单粗暴低价股10元显示4位小数中价股10~100元显示2位小数高价股100元显示2位小数但A股最低变动单位是0.01元所以正常情况2位小数就够了遇到某些特殊股票比如可转债会显示3位。我最后统一保留到3位小数再格式化避免出现因为精度问题显示这种低级bug。涨跌幅我显示的是带正负号的百分比比如2.35%和-1.24%。正号不能丢不然看不出方向。4. 轮询刷新的正确姿势既要实时也要省电4.1 为什么不能搞一个无限循环去刷新最直观的做法是启动一个定时器每隔几秒请求一次接口然后更新UI。这在App停留在前台时没问题但切到后台之后如果还继续跑问题就来了耗电网络请求和解析都是耗电大户后台请求容易被系统限制甚至直接杀掉进程造成不必要的流量消耗在Android 12及以上版本的设备上系统对后台应用的网络访问限制更加严格。所以在这个项目里我的原则很简单只有页面可见时才刷新页面不可见立刻停止。4.2 生命周期感知的刷新方案我的刷新逻辑用协程实现配合Lifecycle组件。核心思路是在repeatOnLifecycle的STARTED状态里启动刷新循环页面进入后台就自动取消Composable fun StockScreen(viewModel: StockViewModel) { val lifecycleOwner LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.startAutoRefresh() } } }startAutoRefresh在ViewModel里就是一个循环请求suspend fun startAutoRefresh() { while (isActive) { refreshQuotes() delay(REFRESH_INTERVAL_MS) } }我设置的刷新间隔是30秒。A股开盘时间内的行情变化30秒基本够用。想更激进可以改成15秒但需要注意接口方的承受能力和自己的流量消耗个人自用15~30秒其实都合理。4.3 请求超时和失败重试策略免费接口不比商业API偶尔超时或返回异常是常态。我的请求策略是单次请求设置5秒超时失败后延迟3秒重试一次再失败就放弃这次刷新保留UI上的旧数据并在页面顶部显示更新失败显示上次数据的提示而不是清空列表。private suspend fun refreshQuotes() { for (attempt in 1..2) { try { val quotes api.fetchQuotes(codes) _quoteList.value quotes return } catch (e: Exception) { if (attempt 2) { _errorMessage.value 行情刷新失败请下拉重试 } else { delay(3000) } } } }这里我踩过一个坑就是请求失败时如果不做处理列表会闪着加载状态但一直不更新用户感知就是卡住了。后来我改成失败静默不弹窗不转圈只是顶部显示一条浅灰色提示体验好很多。5. 自测与真机联调时遇到的坑5.1 模拟器突然连不上行情源开发初期我用模拟器调试请求新浪接口一直失败。后来发现不是接口问题是Android默认禁止HTTP明文流量。从Android 9API 28开始应用的明文HTTP请求默认会被拦截而新浪接口当时只支持HTTP协议。我需要显式开启明文流量支持。在AndroidManifest.xml的application标签里加application android:usesCleartextTraffictrue ...不过这种做法适合个人自用的工具类App。如果是要上架的应用更规范的做法是配置networkSecurityConfig只允许特定域名走明文HTTP其他域名强制HTTPSnetwork-security-config domain-config cleartextTrafficPermittedtrue domain includeSubdomainstruehq.sinajs.cn/domain /domain-config /network-security-config这个配置放在res/xml/network_security_config.xml里然后在AndroidManifest.xml中引用。5.2 行情字段里混入了奇怪字符有一次排查解析异常发现某只停牌股票返回的数据里价格字段全部是0.000而名称字段却正常。当时我的解析代码拿到currentPrice 0.0之后直接计算涨跌幅算出个-100%给用户看这显然是错的。后来我加了过滤逻辑当前价小于等于0视为无效行情列表里显示为--而不是0.00。这个坑没有数据字典光靠看正常行情样本是发现不了的必须在真实请求里主动检查异常字段。5.3 真机测试遇到的HTTPS证书问题换成真机测试后又遇到一个问题某些国产手机上行情接口的HTTPS证书校验失败。原因有一部分是系统时间不准有一部分是厂商ROM自带的证书信任配置与原生Android不同。排查办法先检查系统时间时间不准的先去设置里校准如果还报SSLHandshakeException再用浏览器打开行情地址看是否正常。如果只是自己用可以临时在OkHttp里配置一个宽松的TrustManager但这等于把加密链路的防护拆掉了不建议长期保留。更稳妥的办法是尽量用HTTPS接口并把证书校验失败当成一次普通请求失败处理不做特殊放行。5.4 后台被杀之后重新打开的数据恢复我在开发过程中经常遇到的情况是App在后台被系统回收重新打开后列表是空的。原因是行情数据只存在内存里没有持久化。后来我用Room数据库存了一份最近一次成功的行情快照App启动后先读本地数据秒开页面再异步请求最新行情覆盖。这样用户体验就变成了冷启动瞬间能看到上一次的行情哪怕已经过去几小时然后刷新最新价格。对比之前加载中转圈几秒才看到内容的体验区别还是很明显的。重要提示自选股列表是用户的核心资产必须持久化我用DataStore存了自选股代码列表启动时先读本地配置再加载行情整个流程不依赖网络。6. 一些让我用得很舒服的细节优化6.1 下拉刷新要有反馈虽然自动刷新30秒一次但用户长时间盯着列表时还是想手动确认一下现在的价格。我在列表外层加了SwipeRefresh下拉时立即触发一次刷新。痛点在于Compose里自带的pullRefresh在部分版本上反馈动画不够跟手后来我直接用了Material 3的PullToRefreshBox组件原生托手效果很自然。6.2 行情页打开时自动定位到第一只股票很多时候用户关注的是我持仓的第一只股票因为平时看的次数最多。我加了一个小功能进入自选列表时自动把第一只股票的涨幅钉在页面顶部显眼位置这样打开App不用找就能看到最关心的那只。这个改动虽然简单但实际使用频率非常高。6.3 桌面小组件值得做后来我还给自己的软件加了一个桌面上2x2的小组件显示自选列表前三只的实时行情。桌面组件走的是RemoteViews数据要通过AppWidgetProvider更新。一开始觉得麻烦但做完之后发现这才是最实用的形态——解锁手机不用打开App就能看到行情完全符合简单的初衷。不过桌面组件的刷新机制跟App内刷新完全不同不能复用协程刷新逻辑需要用WorkManager或者AlarmManager定时更新RemoteViews而且系统对桌面组件的刷新频率同样有限制。如果你只想做一个工具体不打算上架这个功能可以放到第二期再考虑。这个项目从开始到稳定运行前后花了一个多月。如今我手机上这类工具类应用越来越多反而更觉得简单是个稀缺品质。行情软件本质是把复杂的数据变成一眼就能看懂的涨跌信息做太多加法反而会失去工具存在的意义。如果你也打算做类似的东西我的建议是从最小闭环开始先把自选列表和实时刷新跑通再根据实际使用感受加功能别在一开始就把App塞得太满。本文还有配套的精品资源点击获取