使用 MediaCodec,进行音频 AAC 硬编

本贴最后更新于 2506 天前,其中的信息可能已经事过景迁

与我们熟知的 MP3 格式一样,AAC 是一种音频编码格式,对比 MP3 格式,AAC 在缩小 30% 的前题下可以提供更好的音质。这篇博客的主要内容就是通过 AudioRecorder 录制 PCM 音频,再通过 MediaCodec 将 PCM 数据硬编码为 AAC 格式的音频。
通常我们使用 MediaCodec 的流程如下:

MediaCodec 的使用流程:

  • createEncoderByType/createDecoderByType
  • configure
  • start
  • while(1) {
  • dequeueInputBuffer
  • queueInputBuffer
  • dequeueOutputBuffer
  • releaseOutputBuffer
  • }
  • stop
  • release

编解码器一个比较经典的工作原理图如下:
media_codec_1png
图中的 Client 一般就是我们开发者,解释一下就是:我们从 Codec 中拿到拿到空的 input buffer,然后填充上我们需要进行编码的数据,再输送给 Codec,Codec 对数据进行编解码,编解码完成后,Codec 将处理好的数据放进 output buffer,我们取出后再清空返还给 Codec,形成一个环形结构。可以看作一个生产者-消费者模式。

下面我们的编码流程也基本遵守上面的这个流程。为了便于大家理解,我画个流程图讲一下我整个代码的逻辑:
imagepng

录音和编码分别在两个线程中进行,两个线程通过一个 ArrayBlockQueue(这是一个线程安全的队列,想了解更多自己动手)队列共享数据,录音线程中的 AudioRecorder 通过 read()将一帧数据 put()到队尾,编码线程中的 MediaCodec 再通过 take()取出队首的一帧数据进行编码。
首先看录音线程,为了使代码更加简洁易懂,我会省略掉一些代码,完整代码会在文末贴出。

/** * 录音线程 */ public class AudioRecorder extends Thread { private AudioRecord mAudioRecord; private boolean isRecording; private int minBufferSize; public AudioRecorder() { isRecording = true; initRecorder(); } @Override public void run() { super.run(); startRecording(); } /** * 初始化录音 */ public void initRecorder(){ minBufferSize = AudioRecord.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat); mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.DEFAULT, sampleRateInHz, channelConfig, audioFormat, minBufferSize); if (mAudioRecord.getState() != AudioRecord.STATE_INITIALIZED) { isRecording = false; return; } } /** * 开始录音 */ public void startRecording(){ if (mAudioRecord == null){ return; } mAudioRecord.startRecording(); while (isRecording) { //自定义的一个类,用来存储一帧pcm数据,即byte[],下面给出具体定义,很简单 AudioDate audioDate = new AudioDate(); audioDate.buffer = ByteBuffer.allocateDirect(minBufferSize); audioDate.size = mAudioRecord.read(audioDate.buffer, minBufferSize); try { if (queue != null) { queue.put(audioDate); } } catch (InterruptedException e) { e.printStackTrace(); } } release(); } }

录音线程比较简单,主要是先初始化录音器在 initRecorder()中,然后通过 AudioRecorder 的 read 方法,获取到一帧数据,通过 queue.put 放入队尾。
然后是编码线程。

/** * 音频编码线程 */ public class AudioEncorder extends Thread { private MediaCodec mEncorder; private Boolean isEncording = false; private int minBufferSize; private OutputStream mFileStream; public AudioEncorder() { isEncording = true; initEncorder(); } @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) @Override public void run() { super.run(); startEncording(); } /** * 初始化编码器 */ private void initEncorder(){ minBufferSize = AudioRecord.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat); try { mEncorder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC); } catch (IOException e) { e.printStackTrace(); } MediaFormat format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRateInHz, channelConfig); format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_AAC); format.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); format.setInteger(MediaFormat.KEY_BIT_RATE, 96000); format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, minBufferSize * 2); mEncorder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); } /** * 开始编码 */ @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) public void startEncording(){ if (mEncorder == null){ return; } mEncorder.start(); try { mFileStream = new FileOutputStream(getSDPath() + "/aac_encode.aac"); MediaCodec.BufferInfo mBufferInfo = new MediaCodec.BufferInfo(); AudioDate audioDate; while (isEncording) { // 从队列中取出录音的一帧音频数据 audioDate = getAudioDate(); if (audioDate == null) { continue; } // 取出InputBuffer,填充音频数据,然后输送到编码器进行编码 int inputBufferIndex = mEncorder.dequeueInputBuffer(0); if (inputBufferIndex >= 0) { ByteBuffer inputBuffer = mEncorder.getInputBuffer(inputBufferIndex); inputBuffer.clear(); inputBuffer.put(audioDate.buffer); mEncorder.queueInputBuffer(inputBufferIndex, 0, audioDate.size, System.nanoTime(), 0); } // 取出编码好的一帧音频数据,然后给这一帧添加ADTS头 int outputBufferIndex = mEncorder.dequeueOutputBuffer(mBufferInfo, 0); while (outputBufferIndex >= 0) { int outBitsSize = mBufferInfo.size; int outPacketSize = outBitsSize + 7; // ADTS头部是7个字节 ByteBuffer outputBuffer = mEncorder.getOutputBuffer(outputBufferIndex); outputBuffer.position(mBufferInfo.offset); outputBuffer.limit(mBufferInfo.offset + outBitsSize); byte[] outData = new byte[outPacketSize]; addADTStoPacket(outData, outPacketSize); outputBuffer.get(outData, 7, outBitsSize); outputBuffer.position(mBufferInfo.offset); mFileStream.write(outData); mEncorder.releaseOutputBuffer(outputBufferIndex, false); outputBufferIndex = mEncorder.dequeueOutputBuffer(mBufferInfo, 0); } } release(); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } /** * 添加ADTS头 * @param packet * @param packetLen */ private void addADTStoPacket(byte[] packet, int packetLen) { int profile = 2; //AAC LC int freqIdx = 4; //44100 根据不同的采样率修改这个值 int chanCfg = 2; //CPE packet[0] = (byte) 0xFF; packet[1] = (byte) 0xF9; packet[2] = (byte) (((profile - 1) << 6) + (freqIdx << 2) + (chanCfg >> 2)); packet[3] = (byte) (((chanCfg & 3) << 6) + (packetLen >> 11)); packet[4] = (byte) ((packetLen & 0x7FF) >> 3); packet[5] = (byte) (((packetLen & 7) << 5) + 0x1F); packet[6] = (byte) 0xFC; } }

和录音线程一样,先进行编码器的初始化在 initEncorder()中,这里的参数 MediaFormat 比较关键,针对具体的编码格式有不同的编码格式有不同的参数,其他的编码方式自行参阅官方文档,这里的 AAC 编码器参数我是经过测试可行的。
然后是最关键的一步--编码。在 startEncording()方法中,所有的流程完全对应上面我列出的 MediaCodec 的使用流程,可以对照上面的流程来阅读代码。
编码流程中非常重要的一步就是为每一帧 AAC 音频添加 ADTS 头,和 WAV 格式的音频不同,AAC 为每一帧音频都添加了一个 ADTS 头,使得解码器可以从任意一帧开始解码,有时我们遇到无法编码的 AAC 文件无法播放,可能就是因为我们没有为其添加 ADTS 头。通过上面的 addADTStoPacket()方法便可以未每一帧 AAC 音频添加 ADTS 头了。
至此,我就列出了我认为在 AAC 编码的过程中所需要注意的所有点。在完成这个功能的时候,我也参照了一些别人的做法,以及我自己的一些理解与实践,也遇到了一些令人头大的错误(例如:同样的 PCM 数据,放进队列中再取出来写入文件,就出现了很大的杂音)如有错误的地方,还请留言指正。
下面是完整代码:

package com.example.sisyphus.audiovideolearning; import android.Manifest; import android.app.Activity; import android.content.pm.PackageManager; import android.media.AudioFormat; import android.media.AudioRecord; import android.media.MediaCodec; import android.media.MediaCodecInfo; import android.media.MediaFormat; import android.media.MediaRecorder; import android.os.Build; import android.os.Bundle; import android.os.Environment; import android.support.annotation.Nullable; import android.support.annotation.RequiresApi; import android.support.v4.app.ActivityCompat; import android.view.MotionEvent; import android.view.View; import android.widget.Button; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.nio.ByteBuffer; import java.util.concurrent.ArrayBlockingQueue; /** * Created by sisyphus on 2018/8/8. * 学习 MediaCodec API,完成音频 AAC 硬编、硬解 * * MediaCodec的使用流程: * - createEncoderByType/createDecoderByType * - configure * - start * - while(1) { * - dequeueInputBuffer * - queueInputBuffer * - dequeueOutputBuffer * - releaseOutputBuffer * } * - stop * - release */public class AACCodecActivity extends Activity { private final int sampleRateInHz = 44100; private final int channelConfig = 1; private final int audioFormat = AudioFormat.ENCODING_PCM_16BIT; private class AudioDate { private ByteBuffer buffer; private int size; } public static String[] MICROPHONE = {Manifest.permission.RECORD_AUDIO}; public static String[] STORAGE = {Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE}; private AudioRecorder mAudioRecorder; private AudioEncorder mAudioEncorder; private ArrayBlockingQueue queue; private Button btnStartRecording; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_aac_codec); btnStartRecording = findViewById(R.id.btn_start_aac_encode); queue = new ArrayBlockingQueue<>(1024); mAudioRecorder = new AudioRecorder(); mAudioEncorder = new AudioEncorder(); btnStartRecording.setOnTouchListener(new View.OnTouchListener() { @Override public boolean onTouch(View view, MotionEvent motionEvent) { int action = motionEvent.getAction(); if (action == MotionEvent.ACTION_DOWN) { checkRecordPermission(); btnStartRecording.setText("正在录制"); startRecord(); } else if (action == MotionEvent.ACTION_UP) { btnStartRecording.setText("录音"); stopRecord(); } return false; } }); } private void startRecord() { mAudioRecorder.start(); mAudioEncorder.start(); } private void stopRecord() { mAudioRecorder.stopRecording(); mAudioEncorder.stopEncording(); } /** * 录音线程 */ public class AudioRecorder extends Thread { private AudioRecord mAudioRecord; private boolean isRecording; private int minBufferSize; public AudioRecorder() { isRecording = true; initRecorder(); } @Override public void run() { super.run(); startRecording(); } /** * 初始化录音 */ public void initRecorder(){ minBufferSize = AudioRecord.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat); mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.DEFAULT, sampleRateInHz, channelConfig, audioFormat, minBufferSize); if (mAudioRecord.getState() != AudioRecord.STATE_INITIALIZED) { isRecording = false; return; } } /** * 释放资源 */ public void release() { if (mAudioRecord != null && mAudioRecord.getState() == AudioRecord.STATE_INITIALIZED) { mAudioRecord.stop(); } } /** * 开始录音 */ public void startRecording(){ if (mAudioRecord == null){ return; } mAudioRecord.startRecording(); while (isRecording) { AudioDate audioDate = new AudioDate(); audioDate.buffer = ByteBuffer.allocateDirect(minBufferSize); audioDate.size = mAudioRecord.read(audioDate.buffer, minBufferSize); try { if (queue != null) { queue.put(audioDate); } } catch (InterruptedException e) { e.printStackTrace(); } } release(); } /** * 结束录音 */ public void stopRecording() { isRecording = false; } } /** * 音频编码线程 */ public class AudioEncorder extends Thread { private MediaCodec mEncorder; private Boolean isEncording = false; private int minBufferSize; private OutputStream mFileStream; public AudioEncorder() { isEncording = true; initEncorder(); } @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) @Override public void run() { super.run(); startEncording(); } /** * 初始化编码器 */ private void initEncorder(){ minBufferSize = AudioRecord.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat); try { mEncorder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC); } catch (IOException e) { e.printStackTrace(); } MediaFormat format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRateInHz, channelConfig); format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_AAC); format.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); format.setInteger(MediaFormat.KEY_BIT_RATE, 96000); format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, minBufferSize * 2); mEncorder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); } /** * 开始编码 */ @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) public void startEncording(){ if (mEncorder == null){ return; } mEncorder.start(); try { mFileStream = new FileOutputStream(getSDPath() + "/aac_encode.aac"); MediaCodec.BufferInfo mBufferInfo = new MediaCodec.BufferInfo(); AudioDate audioDate; while (isEncording) { // 从队列中取出录音的一帧音频数据 audioDate = getAudioDate(); if (audioDate == null) { continue; } // 取出InputBuffer,填充音频数据,然后输送到编码器进行编码 int inputBufferIndex = mEncorder.dequeueInputBuffer(0); if (inputBufferIndex >= 0) { ByteBuffer inputBuffer = mEncorder.getInputBuffer(inputBufferIndex); inputBuffer.clear(); inputBuffer.put(audioDate.buffer); mEncorder.queueInputBuffer(inputBufferIndex, 0, audioDate.size, System.nanoTime(), 0); } // 取出编码好的一帧音频数据,然后给这一帧添加ADTS头 int outputBufferIndex = mEncorder.dequeueOutputBuffer(mBufferInfo, 0); while (outputBufferIndex >= 0) { int outBitsSize = mBufferInfo.size; int outPacketSize = outBitsSize + 7; // ADTS头部是7个字节 ByteBuffer outputBuffer = mEncorder.getOutputBuffer(outputBufferIndex); outputBuffer.position(mBufferInfo.offset); outputBuffer.limit(mBufferInfo.offset + outBitsSize); byte[] outData = new byte[outPacketSize]; addADTStoPacket(outData, outPacketSize); outputBuffer.get(outData, 7, outBitsSize); outputBuffer.position(mBufferInfo.offset); mFileStream.write(outData); mEncorder.releaseOutputBuffer(outputBufferIndex, false); outputBufferIndex = mEncorder.dequeueOutputBuffer(mBufferInfo, 0); } } release(); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } /** * 停止编码 */ public void stopEncording() { isEncording = false; } /** * 从队列中取出一帧待编码的音频数据 * @return */ public AudioDate getAudioDate(){ if (queue != null){ try { return queue.take(); } catch (InterruptedException e) { e.printStackTrace(); } } return null; } /** * 添加ADTS头 * @param packet * @param packetLen */ private void addADTStoPacket(byte[] packet, int packetLen) { int profile = 2; //AAC LC int freqIdx = 4; //44100 根据不同的采样率修改这个值 int chanCfg = 2; //CPE packet[0] = (byte) 0xFF; packet[1] = (byte) 0xF9; packet[2] = (byte) (((profile - 1) << 6) + (freqIdx << 2) + (chanCfg >> 2)); packet[3] = (byte) (((chanCfg & 3) << 6) + (packetLen >> 11)); packet[4] = (byte) ((packetLen & 0x7FF) >> 3); packet[5] = (byte) (((packetLen & 7) << 5) + 0x1F); packet[6] = (byte) 0xFC; } /** * 释放资源 */ public void release() { if (mFileStream != null) { try { mFileStream.flush(); mFileStream.close(); } catch (IOException e) { e.printStackTrace(); } } if (mEncorder != null) { mEncorder.stop(); } } } public String getSDPath() { // 判断是否挂载 if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { return Environment.getExternalStorageDirectory().getAbsolutePath(); } return Environment.getRootDirectory().getAbsolutePath(); } public void checkRecordPermission() { if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, MICROPHONE, 1); return; } if (ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, STORAGE, 1); return; } } }

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...

推荐标签 标签

  • SQLite

    SQLite 是一个进程内的库,实现了自给自足的、无服务器的、零配置的、事务性的 SQL 数据库引擎。SQLite 是全世界使用最为广泛的数据库引擎。

    4 引用 • 7 回帖 • 3 关注
  • CAP

    CAP 指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可兼得。

    12 引用 • 5 回帖 • 634 关注
  • Postman

    Postman 是一款简单好用的 HTTP API 调试工具。

    4 引用 • 3 回帖 • 3 关注
  • ZooKeeper

    ZooKeeper 是一个分布式的,开放源码的分布式应用程序协调服务,是 Google 的 Chubby 一个开源的实现,是 Hadoop 和 HBase 的重要组件。它是一个为分布式应用提供一致性服务的软件,提供的功能包括:配置维护、域名服务、分布式同步、组服务等。

    60 引用 • 29 回帖 • 8 关注
  • CloudFoundry

    Cloud Foundry 是 VMware 推出的业界第一个开源 PaaS 云平台,它支持多种框架、语言、运行时环境、云平台及应用服务,使开发人员能够在几秒钟内进行应用程序的部署和扩展,无需担心任何基础架构的问题。

    5 引用 • 18 回帖 • 189 关注
  • 安全

    安全永远都不是一个小问题。

    199 引用 • 818 回帖 • 1 关注
  • uTools

    uTools 是一个极简、插件化、跨平台的现代桌面软件。通过自由选配丰富的插件,打造你得心应手的工具集合。

    7 引用 • 28 回帖 • 2 关注
  • SSL

    SSL(Secure Sockets Layer 安全套接层),及其继任者传输层安全(Transport Layer Security,TLS)是为网络通信提供安全及数据完整性的一种安全协议。TLS 与 SSL 在传输层对网络连接进行加密。

    70 引用 • 193 回帖 • 413 关注
  • frp

    frp 是一个可用于内网穿透的高性能的反向代理应用,支持 TCP、UDP、 HTTP 和 HTTPS 协议。

    17 引用 • 7 回帖
  • Laravel

    Laravel 是一套简洁、优雅的 PHP Web 开发框架。它采用 MVC 设计,是一款崇尚开发效率的全栈框架。

    19 引用 • 23 回帖 • 738 关注
  • Mac

    Mac 是苹果公司自 1984 年起以“Macintosh”开始开发的个人消费型计算机,如:iMac、Mac mini、Macbook Air、Macbook Pro、Macbook、Mac Pro 等计算机。

    167 引用 • 597 回帖 • 1 关注
  • NGINX

    NGINX 是一个高性能的 HTTP 和反向代理服务器,也是一个 IMAP/POP3/SMTP 代理服务器。 NGINX 是由 Igor Sysoev 为俄罗斯访问量第二的 Rambler.ru 站点开发的,第一个公开版本 0.1.0 发布于 2004 年 10 月 4 日。

    315 引用 • 547 回帖
  • 国际化

    i18n(其来源是英文单词 internationalization 的首末字符 i 和 n,18 为中间的字符数)是“国际化”的简称。对程序来说,国际化是指在不修改代码的情况下,能根据不同语言及地区显示相应的界面。

    8 引用 • 26 回帖 • 3 关注
  • RIP

    愿逝者安息!

    8 引用 • 92 回帖 • 405 关注
  • Sillot

    Insights(注意当前设置 master 为默认分支)

    汐洛彖夲肜矩阵(Sillot T☳Converbenk Matrix),致力于服务智慧新彖乄,具有彖乄驱动、极致优雅、开发者友好的特点。其中汐洛绞架(Sillot-Gibbet)基于自思源笔记(siyuan-note),前身是思源笔记汐洛版(更早是思源笔记汐洛分支),是智慧新录乄终端(多端融合,移动端优先)。

    主仓库地址:Hi-Windom/Sillot

    文档地址:sillot.db.sc.cn

    注意事项:

    1. ⚠️ 汐洛仍在早期开发阶段,尚不稳定
    2. ⚠️ 汐洛并非面向普通用户设计,使用前请了解风险
    3. ⚠️ 汐洛绞架基于思源笔记,开发者尽最大努力与思源笔记保持兼容,但无法实现 100% 兼容
    29 引用 • 25 回帖 • 126 关注
  • WordPress

    WordPress 是一个使用 PHP 语言开发的博客平台,用户可以在支持 PHP 和 MySQL 数据库的服务器上架设自己的博客。也可以把 WordPress 当作一个内容管理系统(CMS)来使用。WordPress 是一个免费的开源项目,在 GNU 通用公共许可证(GPLv2)下授权发布。

    46 引用 • 114 回帖 • 168 关注
  • Sublime

    Sublime Text 是一款可以用来写代码、写文章的文本编辑器。支持代码高亮、自动完成,还支持通过插件进行扩展。

    10 引用 • 5 回帖 • 3 关注
  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3201 引用 • 8217 回帖 • 1 关注
  • 星云链

    星云链是一个开源公链,业内简单的将其称为区块链上的谷歌。其实它不仅仅是区块链搜索引擎,一个公链的所有功能,它基本都有,比如你可以用它来开发部署你的去中心化的 APP,你可以在上面编写智能合约,发送交易等等。3 分钟快速接入星云链 (NAS) 测试网

    3 引用 • 16 回帖 • 1 关注
  • 创业

    你比 99% 的人都优秀么?

    82 引用 • 1395 回帖
  • Ngui

    Ngui 是一个 GUI 的排版显示引擎和跨平台的 GUI 应用程序开发框架,基于
    Node.js / OpenGL。目标是在此基础上开发 GUI 应用程序可拥有开发 WEB 应用般简单与速度同时兼顾 Native 应用程序的性能与体验。

    7 引用 • 9 回帖 • 403 关注
  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    26103 引用 • 108380 回帖
  • CSDN

    CSDN (Chinese Software Developer Network) 创立于 1999 年,是中国的 IT 社区和服务平台,为中国的软件开发者和 IT 从业者提供知识传播、职业发展、软件开发等全生命周期服务,满足他们在职业发展中学习及共享知识和信息、建立职业发展社交圈、通过软件开发实现技术商业化等刚性需求。

    14 引用 • 155 回帖
  • Outlook
    1 引用 • 5 回帖 • 4 关注
  • 正则表达式

    正则表达式(Regular Expression)使用单个字符串来描述、匹配一系列遵循某个句法规则的字符串。

    31 引用 • 94 回帖
  • JetBrains

    JetBrains 是一家捷克的软件开发公司,该公司位于捷克的布拉格,并在俄国的圣彼得堡及美国麻州波士顿都设有办公室,该公司最为人所熟知的产品是 Java 编程语言开发撰写时所用的集成开发环境:IntelliJ IDEA

    18 引用 • 54 回帖 • 2 关注
  • SVN

    SVN 是 Subversion 的简称,是一个开放源代码的版本控制系统,相较于 RCS、CVS,它采用了分支管理系统,它的设计目标就是取代 CVS。

    29 引用 • 98 回帖 • 694 关注