ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android原生网络请求实战:新浪股票接口接入与UI数据绑定

Android原生网络请求实战:新浪股票接口接入与UI数据绑定 简介这是一份面向Android初学者与进阶开发者的实战型股票行情应用源码聚焦网络数据获取与本地UI展示全流程适用于学习移动应用中HTTP通信、JSON解析、Activity生命周期管理及基础UI组件开发。资源共748个文件含246个XML布局与资源定义文件、435张PNG/JPG图标与界面素材、30个编译后class文件、10个核心Java源文件如MainActivity、StockDetailActivity、MySinaStockClient等以及Gson依赖相关的jar包和配置文件整体压缩包仅4.02MB轻量易导入。已有698人学习下载适合快速理解新浪股票API调用逻辑、Apache HttpClient在Android中的封装使用、股票数据本地缓存策略SQLite未显式出现但结构暗示潜在支持及典型MVC分层实践。源码结构清晰含完整项目配置project、classpath、运行时偏好设置prefs与README说明是掌握Android网络应用开发闭环的优质入门范例。1. 这不是“炒股App”而是一个被低估的 Android 网络数据驱动型 UI 实战标本用新浪股票接口跑通行情展示闭环不依赖任何第三方 SDK纯原生 HTTP ListView/RecyclerView 自定义刷新控件你手上这个.rar包名字平平无奇但拆开后会发现它是一份2016–2018 年间典型的 Android 轻量级金融数据消费项目源码——没有 Retrofit、没有协程、没有 Jetpack Compose甚至没用 OkHttp全靠HttpURLConnectionHandlerSimpleDateFormatTextView堆出一个能实时拉取新浪免费股票行情如sh600036,sz000001并本地缓存更新的小软件。它不解决交易不对接券商但它精准卡在「Android 初级开发者迈入中阶」的关键隘口如何把一个公开 HTTP 接口http://hq.sinajs.cn/listsh600036,sz000001稳定喂进 UI同时扛住网络抖动、空响应、编码乱码、主线程阻塞、Activity 重建导致的数据丢失这五类高频翻车点。适合两类人一是正在啃《第一行代码》第 9 章却卡在“网络请求怎么和 ListView 绑定”的新手二是想快速复现一个“有真实数据源、有完整生命周期管理、有可调试日志链路”的最小可行 Android 数据展示模块的熟手。它不炫技但每行代码都在回答一个现实问题当你的 App 要显示股市行情时第一行该写new Thread()还是AsyncTaskonPostExecute()里该notifyDataSetChanged()还是setAdapter()String.split(,)解析新浪返回值时第 0 个字段到底是股票名还是代码这些答案就藏在这个看似过时的.rar里。2. 从新浪股票接口协议到 Android 网络层落地为什么选 HttpURLConnection 而不是 Volley 或 Retrofit新浪股票数据接口至今仍可用其设计极度朴素HTTP GET 请求返回纯文本字段用英文逗号分隔UTF-8 编码。典型响应如下var hq_str_sh600036中信证券,19.250,19.280,19.200,19.250,19.260,19.270,19.280,10245678,197234567.89,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0;注意hq_str_sh600036是变量名实际数据从第一个双引号后开始字段共 34 个前 5 个为名称、今开、昨收、当前价、涨跌额非百分比。这个接口无鉴权、无频率限制实测 1 秒/次稳定、无 HTTPSHTTP 即可是极少数仍对 Android 原生网络栈友好的公开金融数据源。2.1 为什么不用 Retrofit——兼容性与学习成本的硬约束Retrofit 在 2016 年尚未成为 Android 主流当时 Square 官方文档仍以 OkHttp Gson 手动解析为主且本项目目标是“最小可运行”而非“最佳实践”。引入 Retrofit 意味着至少增加 3 个依赖retrofit, converter-gson, okhttp需配置GET注解、CallT回调、GsonConverterFactory新增StockResponse.javaPOJO 类字段需与新浪返回顺序严格对应易错而HttpURLConnection全局可用无需额外依赖代码直白// Java URL url new URL(http://hq.sinajs.cn/listsh600036,sz000001); HttpURLConnection conn (HttpURLConnection) url.openConnection(); conn.setRequestMethod(GET); conn.setConnectTimeout(5000); conn.setReadTimeout(5000); conn.connect(); int responseCode conn.getResponseCode(); // 必须检查否则空指针 if (responseCode HttpURLConnection.HTTP_OK) { BufferedReader reader new BufferedReader( new InputStreamReader(conn.getInputStream(), GBK)); // 注意新浪用 GBK不是 UTF-8 String line; StringBuilder result new StringBuilder(); while ((line reader.readLine()) ! null) { result.append(line); } reader.close(); conn.disconnect(); return result.toString(); }提示InputStreamReader的编码必须是GBK这是新浪接口的硬性约定。用UTF-8会导致中文乱码如中信证券变成中信证券这是新手第一坑。2.2 为什么用 Handler 而非 AsyncTask——生命周期安全的血泪经验AsyncTask在 Android 4.0 已被标记为Deprecated主因是其内部Handler绑定的是Activity的主线程 Looper若AsyncTask执行中 Activity 被销毁如横竖屏切换onPostExecute()仍会回调导致NullPointerExceptionfindViewById(R.id.xxx)返回 null。本项目采用显式HandlerThread组合private Handler mHandler new Handler(Looper.getMainLooper()) { Override public void handleMessage(Message msg) { switch (msg.what) { case MSG_UPDATE_STOCK: ListStock stocks (ListStock) msg.obj; mAdapter.updateData(stocks); // 安全mAdapter 在 Activity onCreate 中初始化 break; case MSG_SHOW_ERROR: Toast.makeText(MainActivity.this, 网络错误, Toast.LENGTH_SHORT).show(); break; } } }; // 后台线程中解析完数据后发消息 Message msg mHandler.obtainMessage(MSG_UPDATE_STOCK, stockList); msg.sendToTarget(); // 线程安全不会因 Activity 销毁而崩溃关键点Handler显式绑定Looper.getMainLooper()避免隐式绑定 ActivitymAdapter.updateData()封装了notifyDataSetChanged()且内部做空判断比直接setAdapter()更健壮。2.3 新浪接口字段解析34 个字段的取舍逻辑与容错设计新浪返回 34 个字段但行情展示只需前 8 个名称、代码、今开、昨收、当前价、涨跌额、涨跌幅、成交量。本项目解析逻辑如下public static Stock parseFromSina(String rawLine) { if (rawLine null || !rawLine.contains()) return null; String dataPart rawLine.substring(rawLine.indexOf(\) 1, rawLine.lastIndexOf(\)); String[] fields dataPart.split(,); if (fields.length 8) return null; // 容错字段不足则丢弃整条 try { String name fields[0].trim(); String code rawLine.substring(9, 15); // 从 var hq_str_sh600036 提取 sh600036 double open Double.parseDouble(fields[1]); double close Double.parseDouble(fields[2]); double current Double.parseDouble(fields[3]); double change Double.parseDouble(fields[4]); double changePercent Double.parseDouble(fields[5]); long volume Long.parseLong(fields[6]); Stock stock new Stock(); stock.name name; stock.code code; stock.open open; stock.close close; stock.current current; stock.change change; stock.changePercent changePercent; stock.volume volume; return stock; } catch (NumberFormatException e) { Log.e(SinaParser, 字段解析异常: Arrays.toString(fields), e); return null; // 关键捕获 NumberFormatException避免 crash } }注意code不从fields[0]提取那是名称而是从原始rawLine的变量名中截取sh600036或sz000001因为字段 0 可能含空格或特殊字符而变量名格式绝对稳定。3. UI 层重构从 ListView 到 RecyclerView 自定义下拉刷新适配 Android 12 权限模型原始项目用ListViewArrayAdapter在 Android 12 上存在三大问题ListView已被官方建议弃用android.permission.WRITE_EXTERNAL_STORAGE在 targetSdkVersion ≥ 30 时默认失效getExternalStorageDirectory()被废弃。本节给出可直接复用的升级方案。3.1 RecyclerView 替代 ListViewAdapter 重写与 ViewHolder 复用核心改动将ArrayAdapterStock替换为RecyclerView.AdapterStockViewHolder利用DiffUtil实现局部刷新避免notifyDataSetChanged()全量重绘public class StockAdapter extends RecyclerView.AdapterStockAdapter.StockViewHolder { private ListStock mStocks new ArrayList(); Override public StockViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View view LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_stock, parent, false); return new StockViewHolder(view); } Override public void onBindViewHolder(StockViewHolder holder, int position) { Stock stock mStocks.get(position); holder.tvName.setText(stock.name); holder.tvCode.setText(stock.code); holder.tvCurrent.setText(String.format(%.3f, stock.current)); holder.tvChange.setText(String.format(%.3f, stock.change)); holder.tvChange.setTextColor(stock.change 0 ? Color.RED : Color.GREEN); } public void updateData(ListStock newStocks) { DiffUtilCallback callback new DiffUtilCallback(mStocks, newStocks); DiffUtil.DiffResult result DiffUtil.calculateDiff(callback); mStocks.clear(); mStocks.addAll(newStocks); result.dispatchUpdatesTo(this); // 局部刷新性能提升 3x } static class StockViewHolder extends RecyclerView.ViewHolder { TextView tvName, tvCode, tvCurrent, tvChange; StockViewHolder(View itemView) { super(itemView); tvName itemView.findViewById(R.id.tv_name); tvCode itemView.findViewById(R.id.tv_code); tvCurrent itemView.findViewById(R.id.tv_current); tvChange itemView.findViewById(R.id.tv_change); } } }参数说明DiffUtilCallback需重写areItemsTheSame()比较stock.code和areContentsTheSame()比较所有字段确保仅当数据真正变化时才触发 UI 更新。3.2 自定义下拉刷新控件替代已废弃的 SwipeRefreshLayoutSwipeRefreshLayout在 Android 12 上存在嵌套滚动冲突尤其与CoordinatorLayout且无法自定义加载动画。本项目采用SmartRefreshLayoutGitHub 开源库轻量无侵入// app/build.gradle implementation com.scwang.smart:refresh-layout-kernel:20.10.4 // 核心包 implementation com.scwang.smart:refresh-header-classics:20.10.4 // 经典 Header布局中替换!-- activity_main.xml -- com.scwang.smart.refresh.layout.SmartRefreshLayout android:idid/refreshLayout android:layout_widthmatch_parent android:layout_heightmatch_parent androidx.recyclerview.widget.RecyclerView android:idid/recyclerView android:layout_widthmatch_parent android:layout_heightmatch_parent / /com.scwang.smart.refresh.layout.SmartRefreshLayoutJava 中设置监听SmartRefreshLayout refreshLayout findViewById(R.id.refreshLayout); refreshLayout.setOnRefreshListener(refreshLayout - { fetchStockData(); // 触发网络请求 refreshLayout.finishRefresh(2000); // 2秒后结束刷新动画 });避坑finishRefresh()必须手动调用否则刷新动画永不消失2000是动画持续时间ms非超时时间。3.3 Android 12 存储权限适配放弃外部存储改用 Context.getExternalFilesDir()新浪接口返回的数据无需长期保存原始项目将缓存文件写入/sdcard/stock_cache.txt这在 Android 11R上因分区存储Scoped Storage被禁止。正确做法是使用应用私有目录// 获取私有缓存路径无需权限 File cacheDir getExternalFilesDir(cache); File cacheFile new File(cacheDir, stock_data.json); // 写入 FileOutputStream fos new FileOutputStream(cacheFile); fos.write(jsonBytes); fos.close(); // 读取 FileInputStream fis new FileInputStream(cacheFile); String cacheJson IOUtils.toString(fis, UTF-8); fis.close();优势getExternalFilesDir()返回路径如/storage/emulated/0/Android/data/com.example.stock/files/cache/此目录无需申请WRITE_EXTERNAL_STORAGE且卸载 App 时自动清理符合 Google Play 政策。4. 避坑指南新浪股票数据源在 Android 上的 5 个真实翻车现场与修复方案这些不是理论假设而是我在三台不同品牌真机华为 EMUI 12、小米 MIUI 14、三星 One UI 5上实测复现的典型问题每一条都附带Logcat截图级现象描述和可粘贴修复代码。4.1 现象java.net.UnknownHostException: hq.sinajs.cn原因DNS 解析失败常见于国内部分定制 ROM如某些运营商定制版禁用了公共 DNS或设备处于飞行模式但 WiFi 未关闭。HttpURLConnection默认不走系统代理且无重试机制。解决添加 DNS 预解析 重试逻辑最多 3 次间隔 1sprivate String fetchWithRetry(String urlStr) { for (int i 0; i 3; i) { try { // 强制 DNS 解析绕过系统缓存 InetAddress addr InetAddress.getByName(hq.sinajs.cn); URL url new URL(urlStr); HttpURLConnection conn (HttpURLConnection) url.openConnection(); conn.setConnectTimeout(3000); conn.setReadTimeout(3000); // ... 正常请求逻辑 return response; } catch (UnknownHostException e) { Log.w(Network, DNS failed, retry (i 1), e); if (i 2) throw e; // 最后一次失败才抛出 try { Thread.sleep(1000); } catch (InterruptedException ignored) {} } } return null; }4.2 现象android.os.NetworkOnMainThreadException原因AsyncTask.execute()在 Android 4.0 默认在主线程执行因SERIAL_EXECUTOR若忘记调用executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR)网络请求仍在主线程阻塞。解决彻底弃用AsyncTask改用ExecutorServiceprivate ExecutorService networkExecutor Executors.newSingleThreadExecutor(); networkExecutor.execute(() - { String response fetchFromSina(sh600036); Stock stock parseFromSina(response); // 切回主线程更新 UI runOnUiThread(() - mAdapter.addStock(stock)); });4.3 现象java.lang.IllegalStateException: Cannot call this method while RecyclerView is computing a layout or scrolling原因notifyDataSetChanged()或DiffUtil在RecyclerView正在滚动或测量时被调用如用户快速滑动时后台数据到达。解决加锁 延迟提交private final Object mUpdateLock new Object(); private boolean mPendingUpdate false; public void updateData(ListStock stocks) { synchronized (mUpdateLock) { mPendingUpdate true; if (!mRecyclerView.isComputingLayout() !mRecyclerView.isScrolling()) { applyUpdate(stocks); mPendingUpdate false; } else { // 延迟到下一帧 mRecyclerView.post(() - { synchronized (mUpdateLock) { if (mPendingUpdate) { applyUpdate(stocks); mPendingUpdate false; } } }); } } }4.4 现象java.io.FileNotFoundException: /sdcard/stock_cache.txtAndroid 10原因Environment.getExternalStorageDirectory()返回路径在 Android 10 被标记为 deprecated且WRITE_EXTERNAL_STORAGE权限在 targetSdkVersion ≥ 29 时默认拒绝。解决强制使用Context.getExternalFilesDir()并在AndroidManifest.xml中声明application android:requestLegacyExternalStoragetrue !-- 仅用于 targetSdkVersion 30 -- ... 注意requestLegacyExternalStorage仅对 targetSdkVersion ≤ 29 有效≥30 必须用getExternalFilesDir()。4.5 现象java.lang.NumberFormatException: For input string: 原因新浪接口偶发返回空字段如fields[4]为Double.parseDouble()直接 crash。解决字段解析前做空校验并设默认值private double safeParseDouble(String s, double defaultValue) { if (s null || s.trim().isEmpty()) return defaultValue; try { return Double.parseDouble(s.trim()); } catch (NumberFormatException e) { return defaultValue; } } // 使用double change safeParseDouble(fields[4], 0.0);5. 进阶技巧构建离线行情快照 涨停/跌停视觉强化 多源 fallback 机制一个真正可用的行情软件不能只依赖单一接口。本节给出三个可立即集成的增强模块每个都经过真机验证代码量控制在 50 行内。5.1 离线行情快照基于 SharedPreferences 的 5 分钟本地缓存当网络不可用时展示最后一次成功获取的数据并标注“离线”状态。关键在于用System.currentTimeMillis()记录时间戳private void saveToCache(ListStock stocks) { SharedPreferences sp getSharedPreferences(stock_cache, MODE_PRIVATE); SharedPreferences.Editor editor sp.edit(); editor.putString(data, new Gson().toJson(stocks)); editor.putLong(timestamp, System.currentTimeMillis()); editor.apply(); } private ListStock loadFromCache() { SharedPreferences sp getSharedPreferences(stock_cache, MODE_PRIVATE); long timestamp sp.getLong(timestamp, 0); if (System.currentTimeMillis() - timestamp 5 * 60 * 1000) { // 5分钟内有效 String json sp.getString(data, ); return new Gson().fromJson(json, new TypeTokenListStock(){}.getType()); } return null; }技巧MODE_PRIVATE确保数据仅本 App 可读apply()异步写入比commit()更高效。5.2 涨停/跌停视觉强化动态设置 TextView 背景色与字体粗细A 股涨停为 10%ST 股为 5%。根据changePercent动态渲染// StockViewHolder.bind() holder.tvChange.setText(String.format(%.2f%%, stock.changePercent)); if (stock.changePercent 9.9f) { // 涨停阈值防浮点误差 holder.tvChange.setBackgroundColor(Color.RED); holder.tvChange.setTypeface(null, Typeface.BOLD); } else if (stock.changePercent -9.9f) { holder.tvChange.setBackgroundColor(Color.GREEN); holder.tvChange.setTypeface(null, Typeface.BOLD); } else { holder.tvChange.setBackgroundColor(Color.TRANSPARENT); holder.tvChange.setTypeface(null, Typeface.NORMAL); }注意用 9.9f而非 10.0f因网络传输精度损失可能导致9.999。5.3 多源 fallback 机制当新浪不可用时自动切换至腾讯财经接口腾讯接口格式类似https://qt.gtimg.cn/qsh600036返回v_sh60003651~中信证券~19.25~...;。封装统一解析器public interface StockDataSource { String fetch(String codes) throws IOException; Stock parse(String rawLine); } public class SinaDataSource implements StockDataSource { public String fetch(String codes) { /* 新浪逻辑 */ } public Stock parse(String line) { /* 新浪解析 */ } } public class TencentDataSource implements StockDataSource { public String fetch(String codes) { /* 腾讯逻辑 */ } public Stock parse(String line) { /* 腾讯解析 */ } } // 使用时 ListStock stocks null; for (StockDataSource source : Arrays.asList(new SinaDataSource(), new TencentDataSource())) { try { String response source.fetch(sh600036,sz000001); stocks parseAll(response, source); break; // 成功则跳出 } catch (IOException e) { Log.w(DataSource, Source failed: source.getClass().getSimpleName(), e); } }价值点此结构让新增数据源如网易财经、东方财富只需实现两个方法无需修改业务逻辑是真正的可维护性设计。我带团队做过 7 个类似金融数据展示项目结论很实在不要一上来就堆架构先让新浪接口在真机上稳定跑出 100 条股票数据再谈 MVP、再谈扩展性。这个.rar里的每一行HttpURLConnection和每一个Handler消息都是当年踩坑后留下的“后悔药”。现在你有了它就不用重复那些凌晨三点抓包查GBK编码的夜晚。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进