Android中的MediaPlayer、SoundPool、Assets。

在一款游戏或者APP中,除了华丽的界面UI直接吸引玩家或用户外,另外重要的就是游戏背景音乐与音效。合适的背景音乐以及精彩的音效搭配会令整个游戏或APP上升一个档次。
在Android中,常用于播放游戏背景音乐的类是MediaPlayer,而用于游戏音效的则是SoundPool类,其中与之相关的还有Assets文件夹。


MediaPlayer:

Android中播放音频文件一般是使用MeduaPlayer类来实现的,它对多种格式的音频文件提供了非常全面的控制方法。
MediaPlayer类常用的函数如下:

方法名功能描述
setDataSource()设置要播放的音频文件的位置
prepare()在开始播放之前调用这个方法完成准备工作
start()开始或继续播放音频
pause()暂停播放音频
reset()将MediaPlayer对象重置到刚刚创建的状态
seekTo()从指定的位置开始播放音频
stop()停止播放音频。调用这个方法后的MediaPlayer对象无法再播放音频
release()释放掉与MediaPlayer对象相关的资源
isPlaying()判断当前MediaPlayer是否正在播放音频
getDuration()获取载入的音频文件的时长
setLooping(boolean looping)设置音乐是否循环播放
getCurrentPosition()得到当前播放音乐的时间点

MediaPlayer的工作流程: 首先需要创建一个MediaPlayer对象,然后调用setDataSource()方法来设置音频文件的路径。再调用prepare()方法使MediaPlayer进入到准备状态,接下来调用start()方法就可以开始播放音频,调用pause()方法就会暂停播放,调用reset()方法就会停止播放。

部分示例代码:

private MediaPlayer mediaPlayer=new MediaPlayer();
private void initMediaPlayer(){
	try{
		File file=new File(Environment.getExternalStorageDirectory(),"music.mp3");//注意声明处理危险权限:<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE/>"
		mediaPlayer.setDataSource(file.getPath());
		mediaPlayer.prepare();
	}catch(Exception e){
		e.printStackTrace();
	}
}
……
public void onClick(View v){
	switch(v.getId()){
		case R.id.play:
		if(!mediaPlayer.isPlaying()){
			mediaPlayer.start();
		}
		break;
		case R.id.pause:
		if(mediaPlayer.isPlaying()){
			mediaPlayer.pause();
		}
		break;
		case R.id.stop:
		if(mediaPlayer.isPlaying()){
			mediaPlayer.reset();
			initMediaPlayer();
		}
		break;
		default:
		break;
	}
}

注意:
实例化MediaPlayer,还可以这样:

mediaPlayer = MediaPlayer.create(context,R.raw.bgmsic);
  • 不得不介绍的是音乐管理类AudioManager,它提供了获取当前音乐大小以及最大音量等。
    AudioManager类常用函数:
函数名参数说明作用
setStreamVolume(int streamType,int index,int flags)streamType:音乐类型(音乐的常量:AudioManager.STREAM_MUSIC);index:音量大小;flags:设置一个或多个标识。设置音量大小
getStreamVolume(int streamType)获取音量大小的类型获取当前音量大小
getStreamMaxVolume(int streamType)获取音量大小的类型获取当前音量最大值

注:在Android中,如果去按手机上调节音量的按钮,会出现2种情况:一是调整手机本身的铃声音量,二是调整游戏、软件的音乐播放的音量。在游戏中有声音在播放的时候,才能去调整游戏的音量。因此往游戏中加入音乐时,需要使用函数:Activity.setVolumeControlStream(int streamType)。其作用是控制音量的类型。
部分示例代码:

public void surfaceCreated(SurfaceHolder holder){
	……
	//实例音乐播放器
	mediaPlayer=MediaPlayer.create(context,R.raw.bgmusic);
	//设置循环播放
	mediaPlayer.setLooping(true);
	//获取音乐文件的总时间
	musicMaxTime=mediaPlayer.getDuration();
	//实例管理类
	am=(AudioManager)MainActivity.instance.getSystemService(Context.AUDIO_SERVICE);
	//设置当前调整音量大小只是针对媒体音乐进行调整
	MainActivity.instance.setVolumeControlStream(AudioManager.STREAM_MUSIC);
	……
}
  • 除了对MediaPlayer常用的操作外,Android还提供了一个接口:
MediaPlayer.OnCompletionListener

其作用是监听音乐是否完全播放完毕。使用这个接口需要注意:

  1. 必须重写一个抽象函数:
    onCompletion(MediaPlayer arg0)onCompletion(MediaPlayer arg0)
    作用:音乐播放完毕会响应此函数
    参数:完成音乐播放的MediaPlayer的实例
  2. 将需要的MediaPlayer实例绑定在完成监听器上:
    setOnCompletionListener(OnCompletionListener)
    注:这个监听音乐播放是否完成的监听器,只针对音乐只播放一次的情况进行监听。也就是说,如果设置了音乐循环播放,那么监听器永远都不会监听到音乐是否播放完成。

SoundPool:

SoundPool也能播放一些音乐文件,他们与MediaPlayer之间最大的区别是SoundPool只能播放小的文件。

SoundPool类的构造函数如下:

构造函数名参数说明作用
SoundPool(int maxStrems,int streamType,int srcQuality)maxStreams:允许同时播放的声音最大值;streamType:声音类型;srcQuality:声音的品质。实例化一个SoundPool实例

SoundPool类中的常用的函数如下:

函数名参数说明作用
int load(Context context,int resId,int priority)context:Context实例;resId:音乐文件id;priority:标识优先考虑的声音。目前使用没有任何效果,只是具备了兼容性价值加载音乐文件,返回音乐ID(音乐流文件数据)
int play(int soundID,float leftVolume,float rightVolume,int priority,int loop,float rate)soundID:加载后得到的音乐文件ID;leftVolume:音量的左声道,范围:0.0~1.0 ;rightVolume:音量的右声道,范围:0.01.0;priority:音乐流的优先级,0是最低优先级;loop:音乐的播放次数,-1表示无限循环,0表示正常1次,大于0表示循环次数;rate:播放速率,取值范围:0.52.0,1.0表示正常播放音乐播放,播放失败返回0,正常播放返回非0值
pause(int streamID)streamID:音乐文件加载后的流ID暂停音乐播放
stop(int streamID)streamID:音乐文件加载后的流ID结束音乐播放
release()释放SoundPool的资源
setLoop(int streamID,int loop)streamID:音乐文件加载后的流ID;loop:循环次数设置循环次数
setRate(int streamID,float rate)streamID:音乐文件加载后的流ID;rate:速率值设置播放速率
setVolume(int streamID,float leftVolume,float rightVolume)streamID:音乐文件加载后的流ID;leftVolume:左声道音量;rightVolume:右声道音量设置音量大小
setPriority(int streamID,int priority)streamID:音乐文件加载后的流ID;priority:优先级值设置流的优先级

使用SoundPool去播放assets下的这些.wav音频文件。
在创建音频播放功能并整合的过程中,还对SoundViewModel的功能整合做单元测试。
Android中的大部分音频API都比较低级,不容易掌握。
SoundPool能加载一批声音资源到内存中,并能控制同时播放的音频文件的个数。
所以,当狂按各个按钮播放音频,也不用担心会搞坏应用或让手机掉电。

SoundPool的使用步骤:

  1. 实现音频播放功能,需要创建一个SoundPool对象:
    mSoundPool=new SoundPool(MAX_SOUNDS, AudioManager.STREAM_MUSIC,0);
    可以使用SoundPool.Builder的方式去创建SoundPool,但是为了兼容Api19,用的是SoundPool(int,int,int)老的构造方法。
    参数1:maxStreams指定同时播放多少个音频。如果超过指定的播放音频数时,SOundPool会停止播放原来的音频。
    参数2:streamType确定音频流类型。Android有很多不同的音频流,它们都有各自独立的音量控制选项。这就是调低音乐音量,闹钟音量却不受影响的原因。
    其中STREAM_MUSIC是音乐和游戏中常用的音量控制常量。其他值请查阅安卓开发文档。
    参数3:srcQuality指定采样率转换品质。参考文档说这个参数不起作用,所以传入了0.

  2. 加载音频文件:
    使用SoundPool加载音频文件,相比其他音频播放方法,SoundPool还有个快速响应的优势:指令一发出,就立即开始播放。
    反应快需要付出代价:播放前必须预先加载音频。SoundPool加载的音频文件都有自己的Integer型ID。使用getter和setter去管理这些ID。
    mSoundId使用的是Integer类型而不是int。这样在Sound的mSoundId没有值的时候,可以设置其为null值。
    然后加载音频,在BeatBox中添加load(SOund sound)方法载入音频。

 private void load(Sound sound) throws IOException {
        AssetFileDescriptor assetFileDescriptor=mAssets.openFd(sound.getAssetPath());
        int soundId=mSoundPool.load(assetFileDescriptor,1);
        sound.setSoundId(soundId);
    }

调用Sound.load()方法可以把文件载入SoundPool待播。为了方便管理、重播或卸载音频文件,Sound.load()会返回一个int型ID。这实际就是存储在mSoundId中的ID。调用openFd()方法有可能抛出IOException,load(Sound)方法也是如此。 然后,在BeatBox.loadSound()方法中调用load()方法载入全部音频文件。

  1. 播放音频:
    在BeatBox类中添加play(Sound)方法:
 public void play(Sound sound){
         Integer soundId=sound.getSoundId();
         if (soundId==null){
             return;
         }
         mSoundPool.play(soundId,1.0f,1.0f,1,0,1.0f);
     }

其中先检查了soundId,不为null后,调用了SoundPool.play(int,float,float,int,int,float)方法播放音频。
参数依次是音频ID、左音量、右音量、优先级(无效)、是否循环、播放速率。需要最大音量和常速播放,所以传入1.0 是否循环参数传入0,代表不循环。(无限循环传入-1)


Assets资源文件夹:

  • 什么是Assets资源文件:
    放在assets文件夹下面的原生资源文件,放在这个文件夹下面的文件不会被R文件编译,所以不能像第一种那样直接使用.Android提供了一个工具类,方便我们操作获取assets文件下的文件:AssetManager。
  • /res和/assets的不同点:
    /res 下的文件是受android系统约束的,1、放在这个文件夹下,会被映射生成R文件,即访问时通过R.xx.xxx;2、只能有一层目录,再往底层建文件夹就访问不到了;3、打包时自动只打包用的上的文件,没用上的文件不打包;获取输入流的方式:
InputStream in =getResources().openRawResource(R.raw.filename);

/assets 下的文件是不受Android的约束的,1、文件放在这个文件夹里,无论是否在项目中用到,会被原封不动的打包到APK; 2、由于没有生成R文件,只能通过路径+文件名(或单独文件名)访问;3、可建立多层次的文件夹(但一般没有这个必要) 获取输入流的方式:

InputStream in = getAssets().open("filename");

二者相同点是:1、作为将来APK的组成部分,要限制大小,单个文件不能超过1M; 2:二者都不会被编译为二进制文件

AssetManager管理对assets文件夹资源的访问
查看官方API可知,AssetManager提供对应用程序的原始资源文件进行访问;这个类提供了一个低级别的API=1,它允许你以简单的字节流的形式打开和读取和应用程序绑定在一起的原始资源文件。主要用到list()及open()方法。
finalString[] list(Stringpath) 返回指定路径下的所有文件及目录名,path是相对路径,是assets子目录。
finalInputStream open(String fileName) 使用 ACCESS_STREAMING模式打开assets下的指定文件,fileName是相对路径,是assets子目录。
finalInputStream open(String fileName,int accessMode) 使用显示的访问模式打开assets下的指定文件。


关于使用Assets资源文件的步骤:

  • 导入assets:
    需要把声音文件添加到项目里,以便应用调用。
    这里没有用资源系统,我们改用了assets打包声音文件。
    可以把assets想象为经过精简的资源:他们也像资源那样打入apk包,但不需要配置系统工具管理。

  • 首先,创建assets目录:
    app->new ->folder->assets folder菜单项,不勾选change folder location,保持target source Set的main选项不变,然后finish完成。
    接着,右击assets目录,选择new ->directory菜单项,为声音资源创建sample_sounds子目录。
    assets目录中的所有文件都会随着应用打包。为了方便组织文件,创建了sample_sounds子目录。与资源不同,子目录不是必须的,这里是为了组织声音文件。

  • 然后重写编译应用,确保一切正常。

  • 处理assets:
    assets导入后,还要能在应用中进行定位、管理记录以及播放。
    需要新建一个名为BeatBox的资源管理类。
    使用AssetManager类访问assets。可以从Context中获取它。构造一个带context的构造函数并留存它。
    通常,在访问assets的时,可以不关心究竟使用了那个context对象。这是因为,在实践中的任何场景下,所有Context中的AssetManager都管理者同一套assets资源。
    要获取assets中的资源清单,可以使用list(String)方法。
    AssetManager.list(String)方法能列出指定目录下的所有文件名。因此,只要传入声音资源所在的目录,就能看到其中的所有的.wav文件。

  • 使用assets:
    获取到资源文件名之后,要显示给用户看,最终需要比方这些声音文件。所以需要创建一个对象,让它管理资源文件名、用户应该看到的文件名以及其他一些相关信息。
    所以创建了Sound管理类。(Sound为我在一个项目中的实体类)
    创建sound对象,并创建sound列表并绑定sound列表。并在recyclerview的设配器中传入列表。


为何使用assets:
事实上,本应用也可以使用Android资源处理。资源可以存储声音文件。比如在res/raw目录下保存文件后,就可以i使用R.raw.***这样的id取到它。
声音文件存储为资源后,就可以像使用其他资源那样使用它们了。例如,可以根据设备的不同方位、语言以及系统版本调用不同的声音资源。
为何选assets?
因为我某个小项目练习案例要用到很多音效,大约20+个声音文件。如果使用android资源系统一个个去处理,效率很低。如果这些文件全放在一个目录下去管理就好了,可惜资源系统不允许这么做。
资源系统做不到的就是assets大显身手的地方。assets可以看作随应用打包的微型文件系统,支持任意层次的文件目录结构。因为这个优点,assets常用来加载大量的图片和声音资源(如游戏)


探讨assets的工作原理: (Sound类是我一个练手项目的实体类)
Sound对象定义了assets文件路径。尝试使用File对象打开资源文件是行不通的。正确的方式是使用AssetManager:
String assetPath=sound.getAssetPath();
InputStream soundData=mAssets.open(assetPath);
这样才能得到标准的InputStream数据流。随后,和Java中的其他InputStream一样,该怎么用就怎么用。
不过,有些API可能还需要FileDescriptor.(SoundPool类就会用到。)
可以改用AssetManager.openFd(String)方法就行了。
String assetPath=sound.getAssetPath();
//AssetFileDescriptors are different from FileDescriptors
AssetFileDescriptor assetFd=mAssets.openFd(assetPath);
//but you gey can a regular FileDescriptor easily if you need to.
FileDescriptor fd=assetFd.getFileDescriptor();


什么是no-assets:
AssetManager类还有像openNonAssetFd()方法。asset专属类为什么要关心non-assets?还真找不到使用它的理由。
简单介绍一下它:
android有assets和resource俩大资源系统。resources系统又良好的检索机制,但无法处理大资源。这些大资源:比如声音和图像文件,通常会保存在assets系统里。
在后台,android就是使用openNonAsset方法来打开它们的。不过,这样的方法有不少没对用户开放。
所以,我们永远没有机会用到它。


实战:webView与js交互
文末,我再来举一个如何加载本地assets资源文件的代码示例,帮助我们初步学会如何使用assets:

  • 项目结构:
    在这里插入图片描述
  • 代码:

activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <WebView
        android:id="@+id/webview"
        android:layout_width="match_parent"
        android:layout_height="200dp"/>

    <TextView
        android:id="@+id/tv_result"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textColor="#cc0000"
        android:textSize="20sp"
        android:layout_marginTop="20dp"
        tools:text="测试数据"/>
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">
        <EditText
            android:id="@+id/edittext"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:minWidth="80dp"/>
        <Button
            android:id="@+id/button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="15dp"
            android:text="发送"/>

    </LinearLayout>
</LinearLayout>

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>WebView</title>
    <style type="text/css">
        body{
            background: #cbcc1a;
        }
        .btn {
            line-height: 40px;
            margin: 10px;
            background: #cccccc;
        }
    </style>
</head>
<body>
<h2>WebView</h2>
<div><span>请输入要传递的值:</span><input type="text" id="input"></div>
<div id="btn"><span class="btn">点我吧!</span></div>

<script type="text/javascript">
    var btnEle = document.getElementById("btn");
    var inputEle=document.getElementById("input");
    btnEle.addEventListener("click",function () {
       var str= inputEle.value;
       // alert(str);
        if (window.myLauncher) {
            myLauncher.setValue(str);
        }else{
            alert("myLauncher not found!")
        }
    });

    var remote=function (str) {
        inputEle.value=str;
    }
</script>
</body>
</html>

jsInterface.java:

package com.example.webviewandjs;

import android.util.Log;
import android.webkit.JavascriptInterface;

public class JsInterface {
    private static final String TAG ="yinlei";

    //声明接口
    private JsBridge jsBridge;

    //构造方法,以便能够调用接口
    public JsInterface(JsBridge jsBridge){
        this.jsBridge=jsBridge;
    }

    /**
     * 经过log打印后可知,这个函数并不是在主线程中执行的。
     * 所以在这个线程方法中去更新UI是不行的。
     * 所以这里给TextView设置value,需要我们去设置handler。
     * @param value
     */
    @JavascriptInterface
    public void setValue(String value){
        Log.d(TAG, "value= "+value);
        jsBridge.setTextViewValue(value);//接口回调
    }

}

jsBridge.java:

package com.example.webviewandjs;

public interface JsBridge {
    void setTextViewValue(String value);
}

MainActivity.java:

package com.example.webviewandjs;

import android.os.Build;
import android.os.Handler;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.webkit.WebView;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;

/**
 * webView与JS的交互
 */

/**
 * WebView调用Java方法:
 * 1.允许WebView加载Js
 * webview.getSettings().setJavaScriptEnabled(true);
 * 2.编写js接口类
 * 3,给WebView添加js接口:
 * webView.addJavaScriptInterface(obj,name);
 */

/**
 * Android调用js方法:
 * 使用loadUrl方法调用javascript:
 * webView.loadUrl(javascript:jsString);
 * jsString是要调用的js代码的字符串
 */

/**
 * Chrome调试:
 * 1.打开允许调试的开关:
 * webView.setWebContentsDebuggingEnabled(true);
 * API19及以上可用。
 * 2.使用Chrome浏览器进行调试:
 * chrome://inspect/#devices
 */

/**
 * Js交互中常见的错误:
 * 1.在js接口的回调方法中throw Exception.(APP不报错,网页端报错)
 * 2.web端不进行对象存在的判断。
 * 3.传递参数类型不一致(尤其是数组和对象)
 * 4.字符串而联系那个参数为空时传递undefined.
 */
public class MainActivity extends AppCompatActivity implements JsBridge{

    private WebView mWebView;
    private TextView mTvResult;
    private Button button;
    private EditText editText;

    private Handler mHandler;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initWidgets(savedInstanceState);

        Log.d("MainActivity", "onCreate: ");
    }

    private void initWidgets(Bundle savedInstanceState) {
        mWebView=findViewById(R.id.webview);
        mTvResult=findViewById(R.id.tv_result);
        editText=findViewById(R.id.edittext);
        button=findViewById(R.id.button);
        mHandler=new Handler();
        //允许webview加载js代码
        mWebView.getSettings().setJavaScriptEnabled(true);

        //给webview添加js接口
        //参数1:obj:js接口类的对象。参数2:name为接口类的对象在js中也是对象,该名字为什么。
        //这里的this是matinActivity,因为它实现了JsBridge接口
        mWebView.addJavascriptInterface(new JsInterface(this),"myLauncher");
        //加载本地的asset文件
        mWebView.loadUrl("file:///android_asset/index.html");

        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                String str=editText.getText().toString();
                Log.d("yl", "onClick: "+str);
                mWebView.loadUrl("javascript:if(window.remote){window.remote('"+str+"')}");
            }
        });


        if (Build.VERSION.SDK_INT >=Build.VERSION_CODES.KITKAT){
        //Chrome调试:打开允许调试的开关
        mWebView.setWebContentsDebuggingEnabled(true);
        }
    }

    @Override
    public void setTextViewValue(final String value) {
        mHandler.post(new Runnable() {
            @Override
            public void run() {
                //run方法里就是在主线程中执行了。
                mTvResult.setText(value);
            }
        });
    }
}

这是一个很简单的WebView与js的交互,关于使用assets也就一句代码:

 //加载本地的asset文件
        mWebView.loadUrl("file:///android_asset/index.html");

总结:
使用assets的2面性:

  1. 无须配置管理,可以随意命名assets,并按照自己的文件结构组织他们;
  2. 没有配置管理,无法自动响应屏幕显示密度、语言这样的设备配置变更,自然也就无法在布局或其他资源里自动使用它们了。
    总体上说,资源系统是更好的选择。但是,如果只是想在代码中直接调用文件,那么assets有优势。大多数游戏就是使用assets加载大量图片和声音资源。

MediaPlayer与SoundPooly优劣分析:
加载长音乐文件生成数据ID,可能会出现错误,错误原因可能是:利用SoundPool播放音乐文件,首先都会对需要播放的音乐文件通过load()函数进行加载,并且生成对应的音乐数据ID;其生成的数据ID(int值)就是整个音乐文件的所有数据,而假设某个长音乐文件长度有42秒,其中的音乐流数据文件也就远远超过了int 的最大值,所以当程序加载此音乐文件生成对应的数据ID时,会报超过最大值的异常。虽然出现这种异常,但还不会导致整个程序崩溃,只是当再播放长的音乐文件时,会发现播放的时间很短,明显感觉到像被剪切了一样。这也证实了SoundPool只能存放1M大小的音乐数据。

  1. 使用MediaPlayer的优缺点:
    1):缺点:资源占用量较高、延迟时间较长、不支持多个音频同时播放等。使用MediaPlayer进行播放音乐时,尤其是在快速连续播放音乐(比如连续猛点按钮)时,会非常明显的出现1~3秒左右的延迟,可以使用MediaPlayer.seekTo()这个方法来解决。
    2):优点:支持很大的音乐文件播放,而且不会同SoundPool一样需要加载准备一段时间,MediaPlayer能及时播放音乐。
  2. 使用SoundPool的优缺点:
    1):缺点:最大只能申请1M的内存空间,意味着用户只能使用一些很短的声音片段,而不能用它来播放歌曲或游戏背景音乐。(建议用来播放游戏音效)。SoundPool提供了pause和stop方法,但建议最好不要轻易使用这些方法,因为使用它们可能会导致程序莫名其妙的终止。使用SoundPool时音频格式建议使用OGG格式。如果使用WAV格式的音频文件,在播放的情况下有时可能会出现异常关闭的情况。在使用SoundPool播放音乐文件的时候,如果在构造中就调用函数进行播放音乐,其效果则是没有声音!不是因为函数没有执行,而是SoundPool需要加载准备时间。如果这个准备时间也很短,就不会影响使用,只是程序一运行播放刚开始会没有声音而已。
    2):优点:支持多个音乐文件同时播放。在Android游戏开发中,游戏背景音乐使用MediaPlayer肯定比使用SoundPool要合适,而游戏音效的播放采用SoundPool则更好,毕竟游戏中肯定会出现多个音效同时进行播放的情况。
GitHub 加速计划 / ass / assets
179
18
下载
Ultralytics assets
最近提交(Master分支:21 天前 )
969b5911 1 个月前
dcb30515 2 个月前
Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐