欢迎关注微信公众号:FSA全栈行动 👋
一、简介
科大讯飞语音评测可以对字、词、句、篇章等题型进行多维度评分(准确度、流畅度、完整度、声韵调型等),支持中文和英文。最新的流式版使用 webSocket 调用接口,开发者可以边录音边上边音频数据(录音与评测同时进行),可以缩短用户等待评测结果的时间,大大提高用户体验。
- 语音评测官方介绍:https://www.xfyun.cn/services/ise
- 语音评测(流式版)API 文档:https://www.xfyun.cn/doc/Ise/IseAPI.html
科大讯飞语音评测提供了多种平台的 SDK 与 Demo,但是没有提供微信小程序版本的 SDK 与 Demo,不过,科大讯飞语音评测【WebApi】有提供浏览器版本的 Demo,理论上可以对它进行适当的改造,就能运行到微信小程序环境下,所以本文的主要内容就是对科大讯飞语音评测【WebApi】的浏览器版本 Demo 进行改造和封装。
温馨提示:着急伸手拿最终成品的,可以直接滚到 【文章末尾】 查看。
二、分析
- API 文档调用示例:https://www.xfyun.cn/doc/Ise/IseAPI.html#调用示例
- 语音评测流式 API demo js 语言:https://xfyun-doc.cn-bj.ufileos.com/static/16546792913730431/ise_ws_js_demo.zip
打开科大讯飞语音评测 API 文档页面,在 调用示例
章节处,找到 语音评测流式API demo js语言
并下载,解压后使用 vscode 打开,查看 index.js
,可以看到开头有如下注释:
// 1. websocket连接:判断浏览器是否兼容,获取websocket url并连接,这里为了方便本地生成websocket url
// 2. 获取浏览器录音权限:判断浏览器是否兼容,获取浏览器录音权限,
// 3. js获取浏览器录音数据
// 4. 将录音数据处理为文档要求的数据格式:采样率16k或8K、位长16bit、单声道;该操作属于纯数据处理,使用webWork处理
// 5. 根据要求(采用base64编码,每次发送音频间隔40ms,每次发送音频字节数1280B)将处理后的数据通过websocket传给服务器,
// 6. 实时接收websocket返回的数据并进行处理// ps: 该示例用到了es6中的一些语法,建议在chrome下运行
通过这个注释说明,可以直观的了解到该 js demo 的大致流程,下面会按照注释里的顺序,逐个分析,并对应到微信小程序里的实现。
注意:因为本人使用的是 uniapp 开发,所以以下关于微信小程序代码实现的部分,
并非
传统的js + wxss + wxml
,而是vue3 + typescript
。
1、创建 webSocket
官方 demo 是运行在浏览器环境下的,而不同的浏览器对 webSocket 的创建方式不太一样,所以这里做兼容:
// 连接websocket
connectWebSocket() {return getWebSocketUrl().then(url => {let iseWSif ('WebSocket' in window) {iseWS = new WebSocket(url)} else if ('MozWebSocket' in window) {iseWS = new MozWebSocket(url)} else {alert('浏览器不支持WebSocket')return}this.webSocket = iseWSiseWS.onopen = e => {...}iseWS.onmessage = e => {...}iseWS.onerror = e => {...}iseWS.onclose = e => {...}})
}
微信小程序内创建 webSocket 就很简单了,使用 uni.connectSocket
即可:
/* socket相关 */
private socketTask: UniApp.SocketTask | null = null;async connect() {const url = await this.getWebSocketUrl();const newUrl = encodeURI(url);this.socketTask = uni.connectSocket({url: newUrl,// 如果希望返回一个 socketTask 对象,需要至少传入 success / fail / complete 参数中的一个complete: () => {},});this.socketTask.onOpen((res) => {...});this.socketTask.onMessage((res) => {...});this.socketTask.onError((err) => {...});this.socketTask.onClose(() => {...});
}
注意:这里有一个坑,
uni.connectSocket()
如果不指定success/fail/complete
参数中的一个,则返回的不是 SocketTask,而是一个 Promise!我们需要的是 SocketTask,所以这里指定了一个空的 complete 回调函数。
- 论坛帖子:https://ask.dcloud.net.cn/question/63162
- 官方文档:https://uniapp.dcloud.net.cn/api/request/websocket.html
上述代码中有一处十分重要的函数调用,即 getWebSocketUrl()
,它负责生成科大讯飞语音评测【WebApi】的 webSocket 链接,涉及到参数加密:
import CryptoJS from "crypto-js";
import { Base64 } from "js/base64js.js";/*** 获取websocket url* 该接口需要后端提供,这里为了方便前端处理*/
function getWebSocketUrl() {return new Promise((resolve, reject) => {// 请求地址根据语种不同变化var url = "wss://ise-api.xfyun.cn/v2/open-ise";var host = "ise-api.xfyun.cn";var apiKeyName = "api_key";var apiKey = API_KEY;var apiSecret = API_SECRET;var date = new Date().toGMTString();var algorithm = "hmac-sha256";var headers = "host date request-line";var signatureOrigin = `host: ${host}\ndate: ${date}\nGET /v2/open-ise HTTP/1.1`;var signatureSha = CryptoJS.HmacSHA256(signatureOrigin, apiSecret);var signature = CryptoJS.enc.Base64.stringify(signatureSha);var authorizationOrigin = `${apiKeyName}="${apiKey}", algorithm="${algorithm}", headers="${headers}", signature="${signature}"`;var authorization = btoa(authorizationOrigin);url = `${url}?authorization=${authorization}&date=${date}&host=${host}`;resolve(url);});
}
js/base64js.js
是 demo 工程里 js 目录下的一个文件(采用 CommonJs 导入规范),而 crypto-js
是使用 npm 安装的一个第三方模块,理论上只需要将这 2 个模块直接拷贝或 npm 安装集成到项目中就好了,但是我的这个工程用的构建工具是 Vite
,只支持 ES 模块导入规范,并且项目中使用了 TypeScript,所以,为了更好的编码体验,这里替换为支持 ES 模块导入规范且支持 TypeScript 的另外 2 个模块(js-base64
、crypto-es
),与 demo 中的那 2 个模块功能完全相同:
// npm i crypto-es -S
// npm i js-base64 -S
import CryptoES from "crypto-es";
import { Base64 } from "js-base64";/*** @returns 生成wss链接*/
protected getWebSocketUrl(): Promise<string> {if (this.apiKey === "" || this.apiSecret === "") {throw new Error("apiKey、apiSecret must not be empty !!!");}return new Promise<string>((resolve, reject) => {// 请求地址根据语种不同变化let url = "wss://ise-api.xfyun.cn/v2/open-ise";const host = "ise-api.xfyun.cn";const date = (new Date() as any).toGMTString();const apiKeyName = "api_key";const algorithm = "hmac-sha256";const headers = "host date request-line";const signatureOrigin = `host: ${host}\ndate: ${date}\nGET /v2/open-ise HTTP/1.1`;const signatureSha = CryptoES.HmacSHA256(signatureOrigin, this.apiSecret);const signature = CryptoES.enc.Base64.stringify(signatureSha);const authorizationOrigin = `${apiKeyName}="${this.apiKey}", algorithm="${algorithm}", headers="${headers}", signature="${signature}"`;const authorization = Base64.encode(authorizationOrigin);url = `${url}?authorization=${authorization}&date=${date}&host=${host}`;resolve(url);});
}
Date#toGMTString()
是一个过时的方法,在 TypeScript 中不被识别,从而导致编译不通过,除了像上述代码中通过强转 any
来规避外,还可以在 src/env.d.ts
文件中进行如下声明解决,interface Date { toGMTString(): string; }
,如果工程中频繁使用该方法的话,建议用第二种方法,这样就不必每处都写一次强转代码了。
注意:
getWebSocketUrl()
函数内在生成 webSocket url 时,会使用到API_KEY
和API_SECRET
,这 2 个参数需要你自己注册一个科大讯飞的开发者账号之后,在开发者账号后台获取,为了防止被别人盗用,这个 url 的生成逻辑应该放置到自己的业务后端去实现。
2、录音上下文与权限
录音上下文的创建,以及获取录音权限的方式在不同浏览器环境中各不相同,所以官方 Demo 中做了大量兼容判断:
// 初始化浏览器录音
recorderInit() {navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia// 创建音频环境try {this.audioContext = new (window.AudioContext || window.webkitAudioContext)()this.audioContext.resume()} catch (e) {if (!this.audioContext) {alert('浏览器不支持webAudioApi相关接口')return}}// 获取浏览器录音权限if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {navigator.mediaDevices....then(stream => { getMediaSuccess(stream) }).catch(e => { getMediaFail(e) })} else if (navigator.getUserMedia) {navigator.getUserMedia(...,stream => { getMediaSuccess(stream) },function(e) { getMediaFail(e) })} else {...alert('无法获取浏览器录音功能,请升级浏览器或使用chrome')this.audioContext && this.audioContext.close()return}// 获取浏览器录音权限成功的回调let getMediaSuccess = stream => {console.log('getMediaSuccess')// 创建一个用于通过JavaScript直接处理音频this.scriptProcessor = this.audioContext.createScriptProcessor(0, 1, 1)this.scriptProcessor.onaudioprocess = e => {// 去处理音频数据...}// 创建一个新的MediaStreamAudioSourceNode 对象,使来自MediaStream的音频可以被播放和操作this.mediaSource = this.audioContext.createMediaStreamSource(stream)// 连接this.mediaSource.connect(this.scriptProcessor)this.scriptProcessor.connect(this.audioContext.destination)...}let getMediaFail = (e) => {alert('请求麦克风失败')this.audioContext && this.audioContext.close()this.audioContext = undefined...}
}
而在微信小程序环境下,获取录音上下文与权限就简单多了,通过 uni.getRecorderManager()
即可获取录音上下文(管理器),然后调用 start(option)
时会自动询问用户是否授权录音权限:
const recordManager = uni.getRecorderManager();/*** 开始录音*/
const startRecord = () => {recordManager.onStart(() => {console.log("recorder start");...});recordManager.onPause(() => {console.log("recorder pause");});recordManager.onStop((res) => {// tempFilePath String 录音文件的临时路径console.log("recorder stop", res);...});recordManager.onError((err) => {// errMsg String 错误信息console.log("recorder err", err);});recordManager.onFrameRecorded((res) => {// frameBuffer ArrayBuffer 录音分片结果数据// isLastFrame Boolean 当前帧是否正常录音结束前的最后一帧const { frameBuffer } = res;...});recordManager.start(option);
};
3、获取录音数据
浏览器的录音数据是通过 scriptProcessor.onaudioprocess
回调函数获得的:
// 获取浏览器录音权限成功的回调
let getMediaSuccess = (stream) => {...this.scriptProcessor.onaudioprocess = (e) => {// 去处理音频数据transWorker.postMessage(e.inputBuffer.getChannelData(0));};...
};
微信小程序的录音数据是通过 recordManager.onFrameRecorded()
回调函数获得:
recordManager.onFrameRecorded((res) => {// frameBuffer ArrayBuffer 录音分片结果数据// isLastFrame Boolean 当前帧是否正常录音结束前的最后一帧const { frameBuffer } = res;pushAudioData(frameBuffer); // 将每一帧音频保存起来
});
4、录音数据格式
- 语音评测接口要求:https://www.xfyun.cn/doc/Ise/IseAPI.html#接口要求
从上面的 语音评测接口要求
中可以知道,科大讯飞语音评测接口对录音数据的格式是有要求的:
内容 | 说明 |
---|---|
音频属性 | 采样率 16k、位长 16bit、单声道 |
音频格式 | pcm、wav、mp3(需更改 aue 的值为 lame)、speex-wb; |
音频大小 | 音频数据发送会话时长不能超过 5 分钟 |
官方 Demo 中,通过 scriptProcessor.onaudioprocess
回调拿到的录音数据是双声道的 PCM 数据,而接口要求的是单声道,所以通过代码 e.inputBuffer.getChannelData(0)
提取出第 1 个声道的数据,并交给 transWorker 去处理成 采样率 16k
、位长 16bit
的 PCM 数据:
// index.js
this.scriptProcessor.onaudioprocess = (e) => {// 去处理音频数据transWorker.postMessage(e.inputBuffer.getChannelData(0));
};// transcode.worker.js
(function(){self.onmessage = function(e){transAudioData.transcode(e.data)}let transAudioData = {transcode(audioData) {let output = transAudioData.to16kHz(audioData)output = transAudioData.to16BitPCM(output)...},to16kHz(audioData) {...return newData},to16BitPCM(input) {...return dataView},}
})()
微信小程序中在启动录音时需要传入一个配置参数 option
,在这个 option
中我们可以指定采样率、通道数等配置,之后通过 recordManager.onFrameRecorded()
回调拿到的就已经是 采样率 16k
、位长 16bit
的单声道 PCM 数据了:
const recordManager = uni.getRecorderManager();
const option = {duration: duration, // 录音的时长,单位 ms,最大值 600000(10 分钟)sampleRate: 16000, // 采样率(pc不支持)numberOfChannels: 1, // 录音通道数// encodeBitRate: 48000, // 编码码率(默认就是48000)frameSize: 1, // 指定帧大小,单位 KB。传入 frameSize 后,每录制指定帧大小的内容后,会回调录制的文件内容,不指定则不会回调。暂仅支持 mp3、pcm 格式。format: "pcm", // 音频格式,默认是 aac
}
const startRecord = () => {...recordManager.onFrameRecorded((res) => {// frameBuffer ArrayBuffer 录音分片结果数据// isLastFrame Boolean 当前帧是否正常录音结束前的最后一帧const { frameBuffer } = res;pushAudioData(frameBuffer); // 将每一帧音频保存起来});recordManager.start(option);
};
注意:微信小程序录音时长最大值为 10 分钟,而科大讯飞语音评测录音时长最大值为 5 分钟!!
5、发送 webSocket 数据
官方 Demo 中,使用 1 个 audioData 数组来存放每一帧经过 transWorker 处理过的 PCM "散装"
数据(音频流数据);在 webSocket 连接开启的 500ms 之后,开始发送音频流数据:
class IseRecorder {constructor({ language, accent, appId } = {}) {// 记录音频数据this.audioData = []transWorker.onmessage = function (event) {self.audioData.push(...event.data) // GitLqr: 注意这里是 "散装" 的}...}recorderStart() {this.audioContext.resume()this.connectWebSocket()}// 连接websocketconnectWebSocket() {return getWebSocketUrl().then(url => {...iseWS.onopen = e => {// 重新开始录音setTimeout(() => {this.webSocketSend()}, 500)}})}// 初始化浏览器录音recorderInit() {...// 获取浏览器录音权限成功的回调let getMediaSuccess = stream => {this.scriptProcessor.onaudioprocess = e => {// 去处理音频数据transWorker.postMessage(e.inputBuffer.getChannelData(0));}...}}
}
再来看 webSocketSend()
的具体实现,就是不断从 audioData 数组中取出 "1280字节的"
PCM 数据,经过 Base64 编码后发送给科大讯飞接口;PCM 数据只会在 第一帧
和 中间帧
进行发送,当 PCM 数据消费完之后,还需要再发送一个 最后帧
通知科大讯飞语音评测接口数据流已经全部发送完成;此外,需要注意第一帧的几个重要参数:
- category:题型。
read_sentence
指的是句子朗读,read_chapter
指的是篇章朗读,等等。 - auf:音频采样率。不填默认就是
audio/L16;rate=16000
。 - ent:中文填
cn_vip
;英文填en_vip
。 - text:待评测文本 utf8 编码,需要加 utf8bom 头。
注意:官方 Demo 的对比句子是
where are you
,所以 ent 填写的是en_vip
,所以,如果实际业务使用的是中文,则需要注意调整参数值。另外,第一帧参数中有用到 appid,这个跟前面的 API_KEY、API_SECRET 一样,都是从开发者账号后台拿到的。
// 向webSocket发送数据webSocketSend() {let audioData = this.audioData.splice(0, 1280)var params = {common: { app_id: this.appId },business: {...category: 'read_sentence', // read_syllable/单字朗读,汉语专有 read_word/词语朗读 read_sentence/句子朗读 https://www.xfyun.cn/doc/Ise/IseAPI.html#%E6%8E%A5%E5%8F%A3%E8%B0%83%E7%94%A8%E6%B5%81%E7%A8%8Bauf: 'audio/L16;rate=16000',ent: 'en_vip',text: '\uFEFF' + 'where are you'},data: {status: 0, encoding: 'raw', data_type: 1,data: this.toBase64(audioData),},}this.webSocket.send(JSON.stringify(params))this.handlerInterval = setInterval(() => {// websocket未连接if (this.webSocket.readyState !== 1) {this.audioData = []clearInterval(this.handlerInterval)return}// 最后一帧if (this.audioData.length === 0) {if (this.status === 'end') {this.webSocket.send(JSON.stringify({business: { cmd: 'auw', aus: 4, aue: 'raw' },data: { status: 2, encoding: 'raw', data_type: 1, data: '', },}))this.audioData = []clearInterval(this.handlerInterval)}return false}audioData = this.audioData.splice(0, 1280)// 中间帧this.webSocket.send(JSON.stringify({business: { cmd: 'auw', aus: 2, aue: 'raw' },data: {status: 1, encoding: 'raw', data_type: 1,data: this.toBase64(audioData),},}))}, 40)}
微信小程序中的数据发送流程跟官方 Demo 差不多,不过对 PCM 数据的存储方式不太一样,在启动录音的时候传入的 option 参数中,frameSize
指定了 onFrameRecorded()
回调的 PCM 帧大小,比如指定为 1(k),那么每次回调时附带的就是 "1024"字节的
PCM 数据,而官方 Demo 中对 PCM 进行拆散(存储时)和切割(发送时)的操作在微信小程序中是没必要的,无形中增加了复杂度。于是我这里干脆使用了一个 audioDataList 数组,将 PCM 数据以一帧帧(每帧 1024 字节)的形式进行存放,发送时,再一帧一帧的取出:
// index.vue
const recordManager = uni.getRecorderManager();
const option = {...frameSize: 1, // 指定帧大小,单位 KB。传入 frameSize 后,每录制指定帧大小的内容后,会回调录制的文件内容,不指定则不会回调。暂仅支持 mp3、pcm 格式。format: "pcm", // 音频格式,默认是 aac
}
const startRecord = () => {...recordManager.onFrameRecorded((res) => {// frameBuffer ArrayBuffer 录音分片结果数据// isLastFrame Boolean 当前帧是否正常录音结束前的最后一帧const { frameBuffer } = res;pushAudioData(frameBuffer); // 将每一帧音频保存起来});recordManager.start(option);
};// ise-xfyun/index.ts
export default class IseXfyun {private audioDataList: ArrayBuffer[] = []; // 音频流数据/*** 添加语音数据* @param frameBuffer 帧数据*/pushAudioData(frameBuffer: any) {if (frameBuffer) {this.audioDataList.push(frameBuffer);}}/*** 发送语音数据*/private sendAudioData() {const audioData = this.audioDataList.splice(0, 1);const params = {common: { app_id: this.appId },business: {...category: this.category,ent: "cn_vip", // 中文text: "\uFEFF" + this.chapter,},data: {status: 0, encoding: "raw", data_type: 1,data: this.toBase64(audioData[0]),},};this.socketTask.send({ data: JSON.stringify(params) });this.handlerInterval = setInterval(() => {// websocket未连接if (!this.socketTask) { return this.clearHandlerInterval(); }// 最后一帧if (this.audioDataList.length === 0) {const params = ...;this.socketTask.send({ data: JSON.stringify(params) });this.audioDataList = [];return this.clearHandlerInterval();}// 中间帧const audioData = this.audioDataList.splice(0, 1);const params = {business: { cmd: "auw", aus: 2, aue: "raw" },data: {status: 1, encoding: "raw", data_type: 1,data: this.toBase64(audioData[0]),},};this.socketTask.send({ data: JSON.stringify(params) });}, 40);}private toBase64(buffer: ArrayBuffer) {return uni.arrayBufferToBase64(buffer);}...
}
注意:这里的
toBase64()
也与官方 Demo 中的实现不一样,官方 Demo 使用的是js/base64js.js
中 Base64 来对数据进行编码,但是实际测试在微信小程序环境中编码会出问题,具体表现为调用科大讯飞语音评测接口时,会返回68675
错误码,改用uni.arrayBufferToBase64()
产出的 base64 则可被科大讯飞正常识别。
- 接口错误码:https://www.xfyun.cn/doc/Ise/IseAPI.html#错误码
6、接收 webSocket 消息
官方 Demo 在创建好 webSocket 之后,指定了消息回调处理函数,具体处理逻辑交由 result(resultData)
实现,回调次数为多次,比如:帧数据接收成功消息,数据流中途错误消息,以及最终的评测结果消息。需要注意的是,如果在中途收到了科大讯飞接口的错误消息,那么应该直接停止数据流发送并提示给用户,因为一旦接口返回了错误消息,那么后续发送的中间帧数据科大讯飞接口是不会受理的,纯属浪费用户的流量和时间:
// 连接websocket
connectWebSocket() {return getWebSocketUrl().then(url => {let iseWS...iseWS.onmessage = e => {this.result(e.data)}})
}result(resultData) {// 识别结束let jsonData = JSON.parse(resultData)if (jsonData.data && jsonData.data.data) {let data = Base64.decode(jsonData.data.data)let grade = parser.parse(data, {attributeNamePrefix: '',ignoreAttributes: false})// 显示成绩...}if (jsonData.code === 0 && jsonData.data.status === 2) {this.webSocket.close()}if (jsonData.code !== 0) {this.webSocket.close()console.log(`${jsonData.code}:${jsonData.message}`)}
}
微信小程序接收并处理 webSocket 消息的逻辑与官方 Demo 类似,不多赘述:
import { Base64 } from "js-base64";
import { XMLParser } from "fast-xml-parser";export default class IseXfyun {/*** 连接科大讯飞wss接口*/async connect() {this.socketTask = uni.connectSocket(...);this.socketTask.onMessage((res) => {this.onMessage(res.data.toString());});...}private onMessage(resultData: string) {const jsonData = JSON.parse(resultData);this.log("收到消息:", jsonData);if (jsonData.data && jsonData.data.data) {const data = Base64.decode(jsonData.data.data);const parser = new XMLParser({attributeNamePrefix: "",ignoreAttributes: false,allowBooleanAttributes: true,});const grade = parser.parse(data);// 显示成绩...}if (jsonData.code === 0 && jsonData.data.status === 2) {this.disconnect();}if (jsonData.code !== 0) {this.callback.onError(jsonData);this.log(`${jsonData.code}:${jsonData.message}`);this.disconnect();}}...
}
三、封装
以上就是对科大讯飞语音评测官方 js Demo 代码逻辑的逐步分析,官方 Demo 把录音与 webSocket 接口调用写在一个文件中,感觉比较混乱,为了更好的复用,我将 录音 与 语音评测 两者进行拆分,把 语音评测 的部分封装到 IseXfyun
类中,大致类结构如下,内容基本上就是上面分析过程中微信小程序端的实现,为缩短篇幅,方法体内代码以省略号表示:
import { Base64 } from "js-base64";
import { XMLParser } from "fast-xml-parser";
import CryptoES from "crypto-es";/*** 科大讯飞 语音评测(流式版)封装** 注意:需要设置wss域名白名单 wss://ise-api.xfyun.cn* 建议:apiKey、apiSecret 不要存放在本地,应该放在后端,防止被破解。* 最佳做法:继承 IseXfyun,重写 getWebSocketUrl(),从后端获取 socket 链接,即 webSocketUrl 的计算规则由后端实现。** @author GitLqr* @since 2022-08-30*/
export default class IseXfyun {/*** 科大讯飞的语音评测只支持 16k 16bit 单通道* @param duration 录音时长* @returns 录音配置*/getAudioRecordOption(duration: number) { ... }/*** 添加语音数据* @param frameBuffer 帧数据*/pushAudioData(frameBuffer: any) { ... }/*** @returns 生成wss链接*/protected getWebSocketUrl(): Promise<string> { ... }/*** 连接科大讯飞wss接口*/async connect() { ... }/*** 断开连接*/disconnect() { ... }/*** 发送语音数据*/private sendAudioData() { ... }private onMessage(resultData: string) { ... }
}
类 IseXfyun
与 Uniapp 完整版 Demo 的具体代码可访问下面仓库进行查看:
- Uniapp 版 科大讯飞语音评测 Demo:https://github.com/GitLqr/UniappIseXfyun
注意:微信小程序需要配置域名白名单
wss://ise-api.xfyun.cn
如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有Android技术, 还有iOS, Python等文章, 可能有你想要了解的技能知识点哦~