spring-boot ffmpeg 搭建一个音频转码服务
2019独角兽企业重金招聘Python工程师标准>>>
利用FFMPEG实现一个音频转码服务
提供一个音频转码服务,主要是利用ffmpeg实现转码,利用java web对外提供http服务接口
背景
音频转码服务算是比较基础的了,之前一直没做,最近有个需求背景,是将微信的amr格式音频,转换为mp3格式,否则h5页面的音频将无法播放
出于这个转码的场景,顺带着搭建一个多媒体处理服务应用(目标是图片的基本操作,音频、视频的常用操作等)
拟采用的技术
- 图片
- imageMagic/graphicMagic + im4java
- 音频
- ffmpeg +
Runtime.getRuntime().exec(cmd);
- Spring Boot + Spring Mvc 提供http服务接口
本篇重点
使用ffmpeg提供音频转码的服务接口
准备
1. ffmpeg 安装
安装脚本如下
#!/bin/bash## download ffmpge cmd
wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-64bit-static.tar.xz## exact package
xz -d ffmpeg-release-64bit-static.tar.xz
tar -xvf ffmpeg-release-64bit-static.tar
mv ffmpeg-release-64bit-static ffmpeg
cd ffmpeg
测试
进入下载的目录,内部有一个 ffmpeg
的可执行文件,主要利用它来实现音频转码
./ffmpeg -version
查看ffmpeg的版本
转码测试
先准备一个测试文件 test.amr (不要直接从微信的文件夹中获取语音文件,微信做过处理,非标准的amr文件,如果手头没有,可以使用这个测试 amrTestAudio.amr )
转码命令
./ffmpeg -i test.amr test.mp3
然后可以看到新增一个mp3文件,然后用播放器,打开确认是否有问题
2. 工程搭建
使用Spring-Boot 搭建一个Web工程
直接用官网的创建方式即可,这里不做叙述
3. 编码实现
java利用命令行操作方式调用ffmpeg,实现音频转码,一个最简单的实现如下
// cmd 为待执行的命令行
String cmd = "ffmpeg -i src.amr test.mp3";
Process process = Runtime.getRuntime().exec(cmd);
process.waitFor();
就这样就可以了么? 显然并没有这么简陋,先谈谈直接这么用有什么问题
- 扩展性,不好
- 命令行的输出流,异常流没有处理
- 对调用者而言不够友好
- 上面只适用于本地音频转码,如果是对远程的音频,数据流格式的音频就不怎么方便了
出于以上几点,着手实现我们的目标,先看最后的测试case:
@Test
public void testAudioParse() {String[] arys = new String[]{"test.amr","/Users/yihui/GitHub/quick-media/common/src/test/resources/test.amr","http://s11.mogucdn.com/mlcdn/c45406/170713_3g25ec8fak8jch5349jd2dcafh61c.amr"};for (String src : arys) {try {String output = AudioWrapper.of(src).setOutputType("mp3").asFile();System.out.println(output);} catch (Exception e) {e.printStackTrace();}}
}
从使用的角度来看就很是简洁了,输出结果如下
/Users/yihui/GitHub/quick-media/common/target/test-classes/test_out.mp3
/Users/yihui/GitHub/quick-media/common/src/test/resources/test_out.mp3
/tmp/audio/170713_3g25ec8fak8jch5349jd2dcafh61c_out.mp3
实现
前面准备做好,测试的case也提前放出,那么可以看下如何实现了
配置类 AudioOptions
保存最终命令的配置相关信息,用于生成最终的执行命令行
对于音频转码,最终的cmd命令应该是: ffmpeg -i source.amr output.mp3
,因此我们需要的参数有
- 源文件 source.mar
- 输出文件 output.mp3
- 执行命令 ffmpeg
- 可选参数 (ffmpeg带的一些参数)
public class AudioOptions {private String cmd = "ffmpeg -i ";private String src;private String dest;private Map<String, Object> options = new HashMap<>();public String getCmd() {return cmd;}public AudioOptions setCmd(String cmd) {this.cmd = cmd;return this;}public String getSrc() {return src;}public AudioOptions setSrc(String src) {this.src = src;return this;}public String getDest() {return dest;}public AudioOptions setDest(String dest) {this.dest = dest;return this;}public Map<String, Object> getOptions() {return options;}public AudioOptions addOption(String conf, Object value) {options.put("-" + conf, value);return this;}public String build() {StringBuilder builder = new StringBuilder(this.cmd);builder.append(" ").append(this.src);for (Map.Entry<String, Object> entry : options.entrySet()) {builder.append(entry.getKey().startsWith("-") ? " " : " -").append(entry.getKey()).append(" ").append(entry.getValue());}builder.append(" ").append(this.dest);return builder.toString();}
}
Audio处理封装类 AudioWrapper
对外暴露的接口,所有音频相关的操作都通过它来执行,正如上面的测试用例
- 对输入源,我们预留三种调用方式
- 传入path路径(相对路径,绝对路径,网络路径)
- URI 方式 (即传入网络链接方式,等同于上面的网络路径方式)
- InputStream (文件输入流)
命令行调用,通常可选参数比较多,所以我们采用Builder模式来做参数的设置
源码如下
@Slf4j
public class AudioWrapper {public static Builder<String> of(String str) {Builder<String> builder = new Builder<>();return builder.setSource(str);}public static Builder<URI> of(URI uri) {Builder<URI> builder = new Builder<>();return builder.setSource(uri);}public static Builder<InputStream> of(InputStream inputStream) {Builder<InputStream> builder = new Builder<>();return builder.setSource(inputStream);}private static void checkNotNull(Object obj, String msg) {if (obj == null) {throw new IllegalStateException(msg);}}private static boolean run(String cmd) {try {return ProcessUtil.instance().process(cmd);} catch (Exception e) {log.error("operate audio error! cmd: {}, e: {}", cmd, e);return false;}}public static class Builder<T> {/*** 输入源*/private T source;/*** 源音频格式*/private String inputType;/*** 输出音频格式*/private String outputType;/*** 命令行参数*/private Map<String, Object> options = new HashMap<>();/*** 临时文件信息*/private FileUtil.FileInfo tempFileInfo;private String tempOutputFile;public Builder<T> setSource(T source) {this.source = source;return this;}public Builder<T> setInputType(String inputType) {this.inputType = inputType;return this;}public Builder<T> setOutputType(String outputType) {this.outputType = outputType;return this;}public Builder<T> addOption(String conf, Object val) {this.options.put(conf, val);return this;}private String builder() throws Exception {checkNotNull(source, "src file should not be null!");checkNotNull(outputType, "output Audio type should not be null!");tempFileInfo = FileUtil.saveFile(source, inputType);tempOutputFile = tempFileInfo.getPath() + "/" + tempFileInfo.getFilename() + "_out." + outputType;return new AudioOptions().setSrc(tempFileInfo.getAbsFile()).setDest(tempOutputFile).addOption("y", "") // 覆盖写.addOption("write_xing", 0) // 解决mac/ios 显示音频时间不对的问题.addOption("loglevel", "quiet") // 不输出日志.build();}public InputStream asStream() throws Exception {String output = asFile();if (output == null) {return null;}return new FileInputStream(new File(output));}public String asFile() throws Exception {String cmd = builder();return !run(cmd) ? null : tempOutputFile;}}}
上面的逻辑还是比较清晰的,但是有几个地方需要注意
- 保存源文件到指定目录下
tempFileInfo = FileUtil.saveFile(source, inputType);
- 执行命令的生成 :
new AudioOptions().setSrc(tempFileInfo.getAbsFile()).setDest(tempOutputFile).addOption("y", "") // 覆盖写.addOption("write_xing", 0) // 解决mac/ios 显示音频时间不对的问题.addOption("loglevel", "quiet") // 不输出日志.build();
- java执行cmd命令
private static boolean run(String cmd)
文件保存 FileUtil
这个工具类的目的比较清晰, 将源文件保存到指定的临时目录下,根据我们支持的三种方式,进行区分处理
我们定义一个数据结构 FileInfo 保存文件名相关信息
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public static class FileInfo {/*** 文件所在的目录*/private String path;/*** 文件名 (不包含后缀)*/private String filename;/*** 文件类型*/private String fileType;public String getAbsFile() {return path + "/" + filename + "." + fileType;}
}
根据输入,选择不同的实现方式保存,并返回文件信息
public static <T> FileInfo saveFile(T src, String inputType) throws Exception {if (src instanceof String) { // 给的文件路径,区分三中,本地绝对路径,相对路径,网络地址return saveFileByPath((String) src);} else if (src instanceof URI) { // 网络资源文件时,需要下载到本地临时目录下return saveFileByURI((URI) src);} else if (src instanceof InputStream) { // 输入流保存在到临时目录return saveFileByStream((InputStream) src, inputType);} else {throw new IllegalStateException("save file parameter only support String/URI/InputStream type! but input type is: " + (src == null ? null : src.getClass()));}}
1. 输入源为String时
三种路径的区分,对于http的格式,直接走URI输入源的方式
相对路径时,需要优先获取文件的绝对路径
/*** 根据path路径 生成源文件信息** @param path* @return* @throws Exception*/
private static FileInfo saveFileByPath(String path) throws Exception {if (path.startsWith("http")) {return saveFileByURI(URI.create(path));}String tmpAbsFile;if (path.startsWith("/")) { // 绝对路径tmpAbsFile = path;} else { // 相对路径转绝对路径tmpAbsFile = FileUtil.class.getClassLoader().getResource(path).getFile();}// 根据绝对路径,解析 目录 + 文件名 + 文件后缀return parseAbsFileToFileInfo(tmpAbsFile);
}/*** 根据绝对路径解析出 目录 + 文件名 + 文件后缀** @param absFile 全路径文件名* @return*/
public static FileInfo parseAbsFileToFileInfo(String absFile) {FileInfo fileInfo = new FileInfo();extraFilePath(absFile, fileInfo);extraFileName(fileInfo.getFilename(), fileInfo);return fileInfo;
}/*** 根据绝对路径解析 目录 + 文件名(带后缀)** @param absFilename* @param fileInfo*/
private static void extraFilePath(String absFilename, FileInfo fileInfo) {int index = absFilename.lastIndexOf("/");if (index < 0) {fileInfo.setPath(TEMP_PATH);fileInfo.setFilename(absFilename);} else {fileInfo.setPath(absFilename.substring(0, index));fileInfo.setFilename(index + 1 == absFilename.length() ? "" : absFilename.substring(index + 1));}
}/*** 根据带后缀文件名解析 文件名 + 后缀** @param fileName* @param fileInfo*/
private static void extraFileName(String fileName, FileInfo fileInfo) {int index = fileName.lastIndexOf(".");if (index < 0) {fileInfo.setFilename(fileName);fileInfo.setFileType("");} else {fileInfo.setFilename(fileName.substring(0, index));fileInfo.setFileType(index + 1 == fileName.length() ? "" : fileName.substring(index + 1));}
}
2. 输入源为URI时
网络资源,需要先把文件下载过来,所以就需要一个下载的工具类
一个非常初级的下载工具类: HttpUtil.java
@Slf4j
public class HttpUtil {public static InputStream downFile(String src) throws IOException {return downFile(URI.create(src));}/*** 从网络上下载文件** @param uri* @return* @throws IOException*/public static InputStream downFile(URI uri) throws IOException {HttpResponse httpResponse;try {Request request = Request.Get(uri);HttpHost httpHost = URIUtils.extractHost(uri);if (StringUtils.isNotEmpty(httpHost.getHostName())) {request.setHeader("Host", httpHost.getHostName());}request.addHeader("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36");httpResponse = request.execute().returnResponse();} catch (Exception e) {log.error("远程请求失败,url=" + uri, e);throw new FileNotFoundException();}int code = httpResponse.getStatusLine().getStatusCode();if (code != 200) {throw new FileNotFoundException();}return httpResponse.getEntity().getContent();}
}
具体的保存代码,比较简单,从网络上下载的InputStream直接转换第三种使用方式即可
/*** 下载远程文件, 保存到临时目录, 病生成文件信息** @param uri* @return* @throws Exception*/
private static FileInfo saveFileByURI(URI uri) throws Exception {String path = uri.getPath();if (path.endsWith("/")) {throw new IllegalArgumentException("a select uri should be choosed! but input path is: " + path);}int index = path.lastIndexOf("/");String filename = path.substring(index + 1);FileInfo fileInfo = new FileInfo();extraFileName(filename, fileInfo);fileInfo.setPath(TEMP_PATH);try {InputStream inputStream = HttpUtil.downFile(uri);return saveFileByStream(inputStream, fileInfo);} catch (Exception e) {log.error("down file from url: {} error! e: {}", uri, e);throw e;}
}
3. 输入源为InpuStream时
将输入流保存到文件
这是一个比较基础的功能了,但真正的实现起来,就没有那么顺畅了,需要注意一下几点
- 确保临时文件所在的目录存在
- 输入输出流的关闭,输出流的
flush()
方法不要忘记 - 保存的临时文件名为: 时间戳 +
[0-1000)随机数
- 输出文件名为输入文件名的基础上加 +
"_out.输出格式"
public static FileInfo saveFileByStream(InputStream inputStream, String fileType) throws Exception {// 临时文件生成规则 当前时间戳 + 随机数 + 后缀return saveFileByStream(inputStream, TEMP_PATH, genTempFileName(), fileType);
}/*** 将字节流保存到文件中** @param stream* @param filename* @return*/
public static FileInfo saveFileByStream(InputStream stream, String path, String filename, String fileType) throws FileNotFoundException {return saveFileByStream(stream, new FileInfo(path, filename, fileType));
}public static FileInfo saveFileByStream(InputStream stream, FileInfo fileInfo) throws FileNotFoundException {if (!StringUtils.isBlank(fileInfo.getPath())) {mkDir(new File(fileInfo.getPath()));}String tempAbsFile = fileInfo.getPath() + "/" + fileInfo.getFilename() + "." + fileInfo.getFileType();BufferedOutputStream outputStream = null;InputStream inputStream = null;try {inputStream = new BufferedInputStream(stream);outputStream = new BufferedOutputStream(new FileOutputStream(tempAbsFile));int len = inputStream.available();//判断长度是否大于4kif (len <= 4096) {byte[] bytes = new byte[len];inputStream.read(bytes);outputStream.write(bytes);} else {int byteCount = 0;//1M逐个读取byte[] bytes = new byte[4096];while ((byteCount = inputStream.read(bytes)) != -1) {outputStream.write(bytes, 0, byteCount);}}return fileInfo;} catch (Exception e) {log.error("save stream into file error! filename: {} e: {}", tempAbsFile, e);return null;} finally {try {if (outputStream != null) {outputStream.flush();outputStream.close();}if (inputStream != null) {inputStream.close();}} catch (IOException e) {log.error("close stream error! e: {}", e);}}
}/*** 临时文件名生成: 时间戳 + 0-1000随机数** @return*/
private static String genTempFileName() {return System.currentTimeMillis() + "_" + ((int) (Math.random() * 1000));
}/*** 递归创建文件夹** @param file 由目录创建的file对象* @throws FileNotFoundException*/
public static void mkDir(File file) throws FileNotFoundException {if (file.getParentFile().exists()) {if (!file.exists() && !file.mkdir()) {throw new FileNotFoundException();}} else {mkDir(file.getParentFile());if (!file.exists() && !file.mkdir()) {throw new FileNotFoundException();}}
}
命令行执行封装工具类 ProcessUtil
这个就是将最上面的三行代码封装的工具类,基本上快两百行...
源码先贴出
@Slf4j
public class ProcessUtil {/*** Buffer size of process input-stream (used for reading the* output (sic!) of the process). Currently 64KB.*/public static final int BUFFER_SIZE = 65536;public static final int EXEC_TIME_OUT = 2;private ExecutorService exec;private ProcessUtil() {exec = new ThreadPoolExecutor(6,12,1,TimeUnit.MINUTES,new LinkedBlockingQueue<>(10),new CustomThreadFactory("cmd-process"),new ThreadPoolExecutor.CallerRunsPolicy());}public static ProcessUtil instance() {return InputStreamConsumer.instance;}/*** 简单的封装, 执行cmd命令** @param cmd 待执行的操作命令* @return* @throws IOException* @throws InterruptedException*/public boolean process(String cmd) throws Exception {Process process = Runtime.getRuntime().exec(cmd);waitForProcess(process);return true;}/*** Perform process input/output and wait for process to terminate.** 源码参考 im4java 的实现修改而来**/private int waitForProcess(final Process pProcess)throws IOException, InterruptedException, TimeoutException, ExecutionException {// Process stdout and stderr of subprocess in parallel.// This prevents deadlock under Windows, if there is a lot of// stderr-output (e.g. from ghostscript called by convert)FutureTask<Object> outTask = new FutureTask<Object>(() -> {processOutput(pProcess.getInputStream(), InputStreamConsumer.DEFAULT_CONSUMER);return null;});exec.submit(outTask);FutureTask<Object> errTask = new FutureTask<Object>(() -> {processError(pProcess.getErrorStream(), InputStreamConsumer.DEFAULT_CONSUMER);return null;});exec.submit(errTask);// Wait and check IO exceptions (FutureTask.get() blocks).try {outTask.get();errTask.get();} catch (ExecutionException e) {Throwable t = e.getCause();if (t instanceof IOException) {throw (IOException) t;} else if (t instanceof RuntimeException) {throw (RuntimeException) t;} else {throw new IllegalStateException(e);}}FutureTask<Integer> processTask = new FutureTask<Integer>(() -> {pProcess.waitFor();return pProcess.exitValue();});exec.submit(processTask);// 设置超时时间,防止死等int rc = processTask.get(EXEC_TIME_OUT, TimeUnit.SECONDS);// just to be on the safe sidetry {pProcess.getInputStream().close();pProcess.getOutputStream().close();pProcess.getErrorStream().close();} catch (Exception e) {log.error("close stream error! e: {}", e);}return rc;}///*** Let the OutputConsumer process the output of the command.* <p>* 方便后续对输出流的扩展*/private void processOutput(InputStream pInputStream,InputStreamConsumer pConsumer) throws IOException {pConsumer.consume(pInputStream);}///*** Let the ErrorConsumer process the stderr-stream.* <p>* 方便对后续异常流的处理*/private void processError(InputStream pInputStream,InputStreamConsumer pConsumer) throws IOException {pConsumer.consume(pInputStream);}private static class InputStreamConsumer {static ProcessUtil instance = new ProcessUtil();static InputStreamConsumer DEFAULT_CONSUMER = new InputStreamConsumer();void consume(InputStream stream) throws IOException {StringBuilder builder = new StringBuilder();BufferedReader reader = new BufferedReader(new InputStreamReader(stream), BUFFER_SIZE);String temp;while ((temp = reader.readLine()) != null) {builder.append(temp);}if (log.isDebugEnabled()) {log.debug("cmd process input stream: {}", builder.toString());}reader.close();}}private static class CustomThreadFactory implements ThreadFactory {private String name;private AtomicInteger count = new AtomicInteger(0);public CustomThreadFactory(String name) {this.name = name;}@Overridepublic Thread newThread(Runnable r) {return new Thread(r, name + "-" + count.addAndGet(1));}}}
说明
- 内部类方式的单例模式
- 线程池,开独立的线程来处理命令行的输出流、异常流
- 如果不清空这两个流,可能直接导致rt随着并发数的增加而线性增加
- 独立的线程执行命令行操作,支持超时设置
- 超时设置,确保服务不会挂住
- 异步执行命令行操作,可以并发执行后续的步骤
填坑之旅
上面实现了一个较好用的封装类,但是在实际的开发过程中,有些问题有必要单独的拎出来说一说
1. -y
参数
覆盖写,如果输出的文件名对应的文件已经存在,这个参数就表示使用新的文件覆盖老的
在控制台执行转码时,会发现这种场景会要求用户输入一个y/n来表是否继续转码,所以在代码中,如果不加上这个参数,将一直得不到执行
2. mac/ios 的音频长度与实际不符合
将 amr 音频转换 mp3 格式音频,如果直接使用命令ffmpeg -i test.amr -y out.mp3
会发现输出的音频时间长度比实际的小,但是在播放的时候又是没有问题的;测试在mac和iphone会有这个问题
解决方案,加一个参数 write_xing 0
3. 并发访问时,RT线性增加
执行命令: ffmpeg -i song.ogg -y -write_xing 0 song.mp3
当我们没有手动清空输出流,异常流时,会发现并发请求量越高,rt越高
主要原因是输出信息 & 异常信息没有被消费,而缓存这些数据的空间是有限制的,因此上面我们的ProcessUtil
类中,有两个任务来处理输出流和异常流
还有一种方法就是加一个参数
ffmpeg -i song.ogg -y -write_xing 0 song.mp3 -loglevel quiet
其他
项目源码: https://github.com/liuyueyi/quick-media
个人博客主页: 一灰的博客网站
公众号获取更多:
G
转载于:https://my.oschina.net/u/566591/blog/1359432
spring-boot ffmpeg 搭建一个音频转码服务相关推荐
- 《SpringCloud超级入门》Spring Boot项目搭建步骤(超详细)《六》
目录 编写第一个 REST 接口 读取配置文件 profiles 多环境配置 热部署 actuator 监控 自定义 actuator 端点 统一异常处理 异步执行 随机端口 编译打包 在 Sprin ...
- 【Spring Boot】使用Spring Boot来搭建Java web项目以及开发过程
[Spring Boot]使用Spring Boot来搭建Java web项目以及开发过程 一.Spring Boot简介 Spring Boot是由Pivotal团队提供的全新框架,其设计目的是用来 ...
- Spring Boot 面试,一个问题就干趴下了!
最近栈长面试了不少人,其中不乏说对 Spring Boot 非常熟悉的,然后当我问到一些 Spring Boot 核心功能和原理的时候,没人能说得上来,或者说不到点上,可以说一个问题就问趴下了! 这是 ...
- Spring Boot Dubbo 应用启停源码分析
作者:张乎兴 来源:Dubbo官方博客 背景介绍 Dubbo Spring Boot 工程致力于简化 Dubbo RPC 框架在Spring Boot应用场景的开发.同时也整合了 Spring Boo ...
- Spring Boot 2.x 启动全过程源码分析(全)
上篇<Spring Boot 2.x 启动全过程源码分析(一)入口类剖析>我们分析了 Spring Boot 入口类 SpringApplication 的源码,并知道了其构造原理,这篇我 ...
- Spring Boot 2.x 启动全过程源码分析(上)入口类剖析
转载自 Spring Boot 2.x 启动全过程源码分析(上)入口类剖析 Spring Boot 的应用教程我们已经分享过很多了,今天来通过源码来分析下它的启动过程,探究下 Spring Boo ...
- Spring Boot框架搭建
目录 一.Spring Boot概述 二.Spring Boot的优点 三.Spring Boot框架搭建 一.Spring Boot概述 Spring Boot 是 Spring 框架的一个新的子项 ...
- spring boot ELK搭建
ELK简介 ELK是Elasticsearch+Logstash+Kibana简称 Elasticsearch 是一个分布式的搜索和分析引擎,可以用于全文检索.结构化检索和分析,并能将这三者结合起来. ...
- Spring Boot 面试,一个问题就干趴下了。
我看你上面写了熟悉 Spring Boot,那你能讲下为什么我们要用 Spring Boot 吗? 下面我列几个最常见的三个回答: A:Spring Boot 最主要是不用 XML 配置,可以用 Ja ...
最新文章
- string 类的初始化和赋值(程序成长之路的一颗米)
- addr2line 和 tombstone问题分析
- 3.6 SM30维护表数据
- Mybatis的批量更新 bug
- 小笔记,在windows和linux下分开编译、在C\C++下都使用C风格编译
- 董明珠自媒体:格力口罩今日开售 上午预约下午抢购
- [poj3280]Cheapest Palindrome_区间dp
- 使用 PDO 方式将 Session 保存到 MySQL 数据中
- 软件教程给MyEclipse 10增加SVN功能
- 免费java版我的世界下载教程,我的世界java版下载,我的世界java版下载教程
- git 中怎样查看未传送(git push)到远程代码库的(git commit)提交?
- vb.net多线程例子
- sql注入数据库原理详解
- 初识Centos7.5
- python openpyxl怎么将数组写入excel_Python-使用openpyxl模块写入Excel文件
- NS2学习---可视化Tcl生成工具NSG2
- 【ACWing】487. 金明的预算方案
- 我那迷途知返的小羊-linux修复DNS解析问题
- RK 100 上手体验 — 机械键盘客制化入门之选?
- js中的console.log()用法
热门文章
- 华为鸿蒙5g售价,华为首款5G手机售价公布,余承东透露鸿蒙将用于连接家庭设备...
- 怎么编写java_程序员学编程第一步:手把手教你开发第一个Java程序
- 3_python基础—运算符 1
- python中debug有什么用途_Python debug 总结
- java继承父类执行顺序_java中子类继承父类程序执行顺序问题
- python中的一些基础
- 输入框回车多个文本_输入框测试用例,你真的了解输入框测试嘛!
- vim python 代码提示_linux下vim python代码自动补全
- python spyder跑出的数据部分有些不变是怎么回事_解决Python spyder显示不全df列和行的问题...
- ubuntu display