一.概述

    目前就我的认知中,Android实现视频播放的话,有2中方式,第一种是MediaPlayer+surfaceView实现,第二种是直接用VideoView来实现,当然市面上也有一些主流的视频能播放的框架,像Vitamio,ExoPlayer(谷歌官方推荐,四不四听起来就比较牛逼)就非常的强大,支持多种格式的视频播放,ExoPlayer这个框(天)架(团)会在之后更新。今天着重讲的就是第一种实现方式-----------Mediaplayer+SurfaceView

二. 效果              

  话不多说,上效果先....








~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~我是华丽丽的分割线



三.效果的实现         

    surfaceView是个双缓冲机制,当你在播放这一帧的时候,它已经提前帮你加载好后面一帧了,所以播放起视频很流畅。而MediaPlayer中的很多方法是采用了Native的方式,所以往往很多的时候你写的代码逻辑啥的完全是没问题的,可它就是会报运行时错误,所以你要在调用MediaPlayer的方法的时候多写写Try catch,来做提前的预防。         


做这个视频的时候大致分为以下几点:     
1.屏幕适配的问题         
2.横竖屏切换的问题        
3.进度条的更新       

    嗨呀,完成以上几点的任务,面试的时候基本上是洒洒水,某该了!话不多说,先上布局文件先


 <RelativeLayout
    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"
    tools:context="com.example.pxy.demoplayer.MainActivity">
    <RelativeLayout
        android:id="@+id/rl"
        android:layout_width="match_parent"
        android:layout_height="250dp">
        <SurfaceView
            android:id="@+id/rl_sv"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>
        <Button
            android:id="@+id/bt_stop"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerInParent="true" android:text="暂停"/>
        <LinearLayout
            android:id="@+id/ll_video"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_alignParentBottom="true"
            android:layout_toLeftOf="@+id/bt_change"
            android:orientation="horizontal">
            <SeekBar
                android:id="@+id/sb_progress"
                android:layout_width="0dip"
                android:layout_height="wrap_content"
                android:layout_marginRight="4dip"
                android:layout_weight="2" />

            <SeekBar
                android:id="@+id/sb_vol"
                android:layout_width="0dip"
                android:layout_height="wrap_content"
                android:layout_weight="1" />
        </LinearLayout>
        <Button
            android:id="@+id/bt_change"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentBottom="true"
            android:layout_alignParentRight="true"
            android:text="切换" />
    </RelativeLayout>

</RelativeLayout>

2.SurfaceView最主要的就是一个getHolder(),相当于找到一个容器,好比你去足球场踢球,你首先得找个足球场进去,然后他有三个回调监听的方法,我们主要是使用了其中的两个,在surfaceCreated方法中执行播放视频的方法,在surfaceDestroyed方法中执行停止播放视频的方法


      //为了兼容2.3系统,要加上一句话,否则播放的时候只有声音 ,并没有画面的
       //mSvVideo.getHolder().setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
        //增量逻辑和
        mSurfaceView.getHolder().addCallback(new SurfaceHolder.Callback() {
            @Override
            public void surfaceCreated(SurfaceHolder holder) {
                //当画面可见的时候执行
                play();
            }

            @Override
            public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
                //当画面发生变化执行
            }

            @Override
            public void surfaceDestroyed(SurfaceHolder holder) {
                //当画面不见得时候执行
                stop();
            }
        });

      首先我们来see see 牛逼的play()方法,上源码

  /**
     * 播放视频的方法
     */
    private void play() {
        mMediaPlayer = new MediaPlayer();
        AssetFileDescriptor assetFileDescriptor = getResources().openRawResourceFd(R.raw.land);
        try {
            mMediaPlayer.setDataSource(assetFileDescriptor.getFileDescriptor(), assetFileDescriptor.getStartOffset(), assetFileDescriptor.getLength());
            //设置循环播放
            mMediaPlayer.setLooping(true);
            //设置播放区域
            mMediaPlayer.setDisplay(mSvVideo.getHolder());
            //播放时屏幕保持唤醒
            mMediaPlayer.setScreenOnWhilePlaying(true);
            //异步准备播放视频
            mMediaPlayer.prepareAsync();
            mMediaPlayer.setOnPreparedListener(this);
        } catch (IOException e) {
            e.printStackTrace();
        }

  都是简单的该设置的设置,你懂得, 其中重点看下MediaPlayer的prepare状态,API文档中提供同步和异步准备的方法,推荐使用异步准备,有两种方式(同步和异步),该准备状态可以达到:要么调用prepare()同步)的对象转移到准备状态,一旦方法调用返回,或者调用prepareAsync()异步),它首先调用返回后对象到准备状态(发生几乎是正确的方式),而内部播放引擎将继续对准备工作的其他工作,直到准备工作完成传输。 当准备完成时或prepare()调用返回时,内部播放引擎,然后调用OnPreparedListener界面的用户提供的回调方法。还可以设置一个播放错误的监听

    @Override
    public void onPrepared(MediaPlayer mp) {
        //设置一个播放错误的监听
        mp.setOnErrorListener(new MediaPlayer.OnErrorListener() {
            @Override
            public boolean onError(MediaPlayer mp, int what, int extra) {
                return false;
            }
        });
        mSbProgress.setMax(mMediaPlayer.getDuration());
        //先设置视频播放的大小
        setVideoParamter(mMediaPlayer, getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE);
        mp.start();
        startProgress();
    }



其中setVideoParamter()方法是适配屏幕和横竖屏切换的,先拿到SurfaceView本身和父布局的布局参数,我在竖屏设置宽高比为16:9,然后就是surfaceView的宽高比和mediaPlayer的宽高比进行比较,这里有点坑爹的地方就是,宽高比如果你去成int型就会挖个坑给自己,我就在坑里面玩了好久的沙子,int是不会保留小数的,所以去成float最好,代码都比较easy,就不多做介绍了

  /**
     * 设置SurfaceView的参数
     *
     * @param mediaPlayer
     * @param isLand
     */
    public void setVideoParams(MediaPlayer mediaPlayer, boolean isLand) {
        //获取surfaceView父布局的参数
        ViewGroup.LayoutParams rl_paramters = mRlVideo.getLayoutParams();
        //获取SurfaceView的参数
        ViewGroup.LayoutParams sv_paramters = mSvVideo.getLayoutParams();
        //设置宽高比为16/9
        float screen_widthPixels = getResources().getDisplayMetrics().widthPixels;
        float screen_heightPixels = getResources().getDisplayMetrics().widthPixels * 9f / 16f;
        //取消全屏
        getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
        if (isLand) {
            screen_heightPixels = getResources().getDisplayMetrics().heightPixels;
            //设置全屏
            getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
        }
        rl_paramters.width = (int) screen_widthPixels;
        rl_paramters.height = (int) screen_heightPixels;

        //获取MediaPlayer的宽高
        int videoWidth = mediaPlayer.getVideoWidth();
        int videoHeight = mediaPlayer.getVideoHeight();

        float video_por = videoWidth / videoHeight;
        float screen_por = screen_widthPixels / screen_heightPixels;
        //16:9    16:12
        if (screen_por > video_por) {
            sv_paramters.height = (int) screen_heightPixels;
            sv_paramters.width = (int) (screen_heightPixels * screen_por);
        } else {
            //16:9  19:9
            sv_paramters.width = (int) screen_widthPixels;
            sv_paramters.height = (int) (screen_widthPixels / screen_por);
        }
        mRlVideo.setLayoutParams(rl_paramters);
        mSvVideo.setLayoutParams(sv_paramters);
    }

至于横竖屏切换的适配,无非有2种方式,一种是直接在Manifest中把Activity的screenOrientation写死,但是用户体验不好,所以我们一般采用第二种,在 Manifest中调用configChanges,然后 在Activity上覆写 onConfigurationChanged方法,就洒洒水了,看看效果

       <activity
            android:name=".VideoActivity"
            android:configChanges="keyboardHidden|screenSize|orientation">

    @Override
    public void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) {
            //变成横屏了
            setVideoParams(mMediaPlayer, true);
        } else if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) {
            //变成竖屏了
            setVideoParams(mMediaPlayer, false);
        }
    }



忘记说这个切换按钮了,来来来,给按钮设置点击监听,然后判断当前屏幕的状态,再设置横屏或者竖屏,上代码

 @Override
    public void onClick(View v) {
        switch (v.getId()) { 
           ............
  
            case R.id.bt_change:
                if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
                    //变成竖屏
                    setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
                } else if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT) {
                    //变成横屏了
                    setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
                }
                break;
            ............
        }



startProgress()方法是更新视频播放进度条,进度条的控制分为声音和视频进度,都比较简单,先说视频进度条吧,开启子线程更新UI,然后setOnSeekBarChangeListener监听,其中formUser可以判断是否是来自与用户的交互而改变进度,然后是的话,调用mediaPlayer的seekTo方法OK啦

    /**
     * 视频播放的同时进度条开始一起走
     */
    public void startProgress() {
        canProgress = true;
        new Thread() {
            @Override
            public void run() {
                while (canProgress) {
                    try {
                        mSbProgress.setProgress(mMediaPlayer.getCurrentPosition());
                        //这里为了进度条更加明显点
                        sleep(200);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
        }.start();
    }
  //进度条的监听
    @Override
    public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
        if (!fromUser) {
            return;
        }
        switch (seekBar.getId()) {
            case R.id.sb_progress:
                try {
                    mMediaPlayer.seekTo(progress);
                } catch (IllegalStateException e) {
                    e.printStackTrace();
                }
                break;
            case R.id.sb_vol:
                mAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, progress, 0);
                break;
        }
    }

接着是声音进度的监听,我迅速的思索了下我平时是怎么调音量的,有3中方式,1.按手机外壳上的音量键 2.按视频播放里面的有个小喇叭状的按钮3.通过往上下划屏幕来调节音量,我在这就简单的先介绍前面2种啦,第三种比较麻烦(不是我不行,男人不能说不行...咳咳)

首先是按手机外壳上的按键,先找个帮手叫AudioManager,管理着系统里的各种声音,我们这里要用到的就是STREAM_MUSIC,进来先把Max和当前音量大小给先设置上了

mAudioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
mSbVol.setMax(mAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC));
mSbVol.setProgress(mAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC));

然后再呼叫个帮手叫动态广播,用来监视用户是否按了手机外壳上的按钮,第一种方式瞬间搞定

    //监听系统的音量变化的广播
    class MyReceiver extends BroadcastReceiver {

        @Override
        public void onReceive(Context context, Intent intent) {
            mSbVol.setProgress(mAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC));
        }
    }
  new MyReceiver().registerReceiver(myReceiver, new IntentFilter("android.media.VOLUME_CHANGED_ACTION"));


然后就是第二种拖动小喇叭按钮来调节音乐,也很简单,设置一个进度条拖动监听,然后调用AudioManager的setStreamVolume方法就可以了

  mSbVol.setOnSeekBarChangeListener(this);
    //进度条的监听
    @Override
    public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
        if (!fromUser) {
            return;
        }
        switch (seekBar.getId()) {
             .........

            case R.id.sb_vol:
                mAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, progress, 0);
                break;
             ...........
        }
    }

最后就是砸门的stop()方法了,有始有终,方能致远,在Activity finish后,如果不释放mediaplayer占用的资源,有可能会造成内存泄露,建议在Activity销毁的时候,先调用一下mediaplayer.release()释放播放器占用的资源。本Demo中我是在surfaceDestroyed和Activity onDestroy中调用Stop方法的

 

   /**
     * 停止视频播放的方法
     */
    public void stop() {
        try {
            if (mMediaPlayer != null) {
                mMediaPlayer.pause();
                mMediaPlayer.stop();
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            canProgress = false;
            mMediaPlayer.release();
            mMediaPlayer = null;
        }
    }

好啦,介绍了这么多想必大家对Media的使用有了一丢丢的认识了吧,啊,没有,没关系,我再来个关于mediaplayer的常用控制方法总结 微笑 


1.prepare()和prepareAsync() 提供了同步和异步两种方式设置播放器进入prepare状态,需要注意的是,如果MediaPlayer实例是由create方法创建的,那么第一次启动播放前不需要再调用prepare()了,因为create方法里已经调用过了。
2. start()是真正启动文件播放的方法。
3.pause()和stop()比较简单,起到暂停和停止播放的作用。
4.seekTo()是定位方法,可以让播放器从指定的位置开始播放,需要注意的是该方法是个异步方法,也就是说该方法返回时并不意味着定位完成,尤其是播放的网络文件,真正定位完成时会触发OnSeekComplete.onSeekComplete(),如果需要是可以调用setOnSeekCompleteListener(OnSeekCompleteListener)设置监听器来处理的。
5.release()可以释放播放器占用的资源,一旦确定不再使用播放器时应当尽早调用它释放资源。
6.reset()可以使播放器从Error状态中恢复过来,重新会到Idle状态。
:mediaPlayer有很多方法是用native修饰的,调用的底层的东东,这里有点问题,你在用mediaPlayer的时候,要勤用try catch来打个预防针,话不多说,最后的最后我们就简单地来看看MediaPlayer一个native方法,看它究竟是何方妖怪

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~我是华丽丽的分割线


在MediaPlayer.java中的seekTo是一个native修饰的方法

   1: /**
   2:  * Seeks to specified time position.
   3:  *
   4:  * @param msec the offset in milliseconds from the start to seek to
   5:  * @throws IllegalStateException if the internal player engine has not been
   6:  * initialized
   7:  */
   8: public native void seekTo(int msec) throws IllegalStateException;

好,我们来看看此方法的JNI是如何实现的。

   1: static void android_media_MediaPlayer_seekTo(JNIEnv *env, jobject thiz, int msec)
   2: {
   3:     sp<MediaPlayer> mp = getMediaPlayer(env, thiz);//获取MediaPlayer实例
   4:     if (mp == NULL ) {
   5:         jniThrowException(env, "java/lang/IllegalStateException", NULL);
   6:         return;
   7:     }
   8:     LOGV("seekTo: %d(msec)", msec);
   9:     status_t result = mp->seekTo(msec);//1,调用MediaPlayer的seekTo方法
  10:     process_media_player_call( env, thiz, result, NULL, NULL );//2,处理MediaPlayer方法调用的返回结果
  11: }
1,调用MediaPlayer的seekTo方法
   1: status_t MediaPlayer::seekTo_l(int msec)
   2: {
   3:     LOGV("seekTo %d", msec);
   4:     if ((mPlayer != 0) && ( mCurrentState & ( MEDIA_PLAYER_STARTED | MEDIA_PLAYER_PREPARED | MEDIA_PLAYER_PAUSED |  MEDIA_PLAYER_PLAYBACK_COMPLETE) ) ) {
   5:         if ( msec < 0 ) {
   6:             LOGW("Attempt to seek to invalid position: %d", msec);
   7:             msec = 0;
   8:         } else if ((mDuration > 0) && (msec > mDuration)) {
   9:             LOGW("Attempt to seek to past end of file: request = %d, EOF = %d", msec, mDuration);
  10:             msec = mDuration;
  11:         }
  12:         // cache duration
  13:         mCurrentPosition = msec;
  14:         if (mSeekPosition < 0) {
  15:             getDuration_l(NULL);
  16:             mSeekPosition = msec;
  17:             //调用seekTo了
  18:             return mPlayer->seekTo(msec);
  19:         }
  20:         else {
  21:             LOGV("Seek in progress - queue up seekTo[%d]", msec);
  22:             return NO_ERROR;
  23:         }
  24:     }
  25:     LOGE("Attempt to perform seekTo in wrong state: mPlayer=%p, mCurrentState=%u", mPlayer.get(), mCurrentState);
  26:     return INVALID_OPERATION;
  27: }
  28:  
  29: status_t MediaPlayer::seekTo(int msec)
  30: {
  31:     mLockThreadId = getThreadId();
  32:     Mutex::Autolock _l(mLock);
  33:     status_t result = seekTo_l(msec);
  34:     mLockThreadId = 0;
  35:     return result;
  36: }
2,处理MediaPlayer方法调用的返回结果
   1: static void process_media_player_call(JNIEnv *env, jobject thiz, status_t opStatus, const char* exception, const char *message)
   2: {
   3:     if (exception == NULL) {  // Don't throw exception. Instead, send an event.
   4:         if (opStatus != (status_t) OK) {
   5:             sp<MediaPlayer> mp = getMediaPlayer(env, thiz);
   6:             if (mp != 0) mp->notify(MEDIA_ERROR, opStatus, 0);//调用MediaPlayer的notify
   7:         }
   8:     } else {  // Throw exception!
   9:         if ( opStatus == (status_t) INVALID_OPERATION ) {
  10:             jniThrowException(env, "java/lang/IllegalStateException", NULL);
  11:         } else if ( opStatus != (status_t) OK ) {
  12:             if (strlen(message) > 230) {
  13:                // if the message is too long, don't bother displaying the status code
  14:                jniThrowException( env, exception, message);
  15:             } else {
  16:                char msg[256];
  17:                 // append the status code to the message
  18:                sprintf(msg, "%s: status=0x%X", message, opStatus);
  19:                jniThrowException( env, exception, msg);
  20:             }
  21:         }
  22:     }
  23: }

接下来看看MediaPlayer的notify方法,这个方法主要是通过判断MediaPlayer的状态向我们的app发送回调:

   1: void MediaPlayer::notify(int msg, int ext1, int ext2)
   2: {
   3:     LOGV("message received msg=%d, ext1=%d, ext2=%d", msg, ext1, ext2);
   4:     bool send = true;
   5:     bool locked = false;
   6:  
   7:     // TODO: In the future, we might be on the same thread if the app is
   8:     // running in the same process as the media server. In that case,
   9:     // this will deadlock.
  10:     //
  11:     // The threadId hack below works around this for the care of prepare
  12:     // and seekTo within the same process.
  13:     // FIXME: Remember, this is a hack, it's not even a hack that is applied
  14:     // consistently for all use-cases, this needs to be revisited.
  15:      if (mLockThreadId != getThreadId()) {
  16:         mLock.lock();
  17:         locked = true;
  18:     }
  19:  
  20:     if (mPlayer == 0) {
  21:         LOGV("notify(%d, %d, %d) callback on disconnected mediaplayer", msg, ext1, ext2);
  22:         if (locked) mLock.unlock();   // release the lock when done.
  23:         return;
  24:     }
  25:  
  26:     switch (msg) {
  27:     case MEDIA_NOP: // interface test message
  28:         break;
  29:     case MEDIA_PREPARED://prepared结束
  30:         LOGV("prepared");
  31:         mCurrentState = MEDIA_PLAYER_PREPARED;
  32:         if (mPrepareSync) {
  33:             LOGV("signal application thread");
  34:             mPrepareSync = false;
  35:             mPrepareStatus = NO_ERROR;
  36:             mSignal.signal();
  37:         }
  38:         break;
  39:     case MEDIA_PLAYBACK_COMPLETE://播放完毕
  40:         LOGV("playback complete");
  41:         if (!mLoop) {
  42:             mCurrentState = MEDIA_PLAYER_PLAYBACK_COMPLETE;
  43:         }
  44:         break;
  45:     case MEDIA_ERROR://出错
  46:         // Always log errors.
  47:         // ext1: Media framework error code.
  48:         // ext2: Implementation dependant error code.
  49:         LOGE("error (%d, %d)", ext1, ext2);
  50:         mCurrentState = MEDIA_PLAYER_STATE_ERROR;
  51:         if (mPrepareSync)
  52:         {
  53:             LOGV("signal application thread");
  54:             mPrepareSync = false;
  55:             mPrepareStatus = ext1;
  56:             mSignal.signal();
  57:             send = false;
  58:         }
  59:         break;
  60:     case MEDIA_INFO://logcat经常可以看到
  61:         // ext1: Media framework error code.
  62:         // ext2: Implementation dependant error code.
  63:         LOGW("info/warning (%d, %d)", ext1, ext2);
  64:         break;
  65:     case MEDIA_SEEK_COMPLETE://seek完毕
  66:         LOGV("Received seek complete");
  67:         if (mSeekPosition != mCurrentPosition) {
  68:             LOGV("Executing queued seekTo(%d)", mSeekPosition);
  69:             mSeekPosition = -1;
  70:             seekTo_l(mCurrentPosition);
  71:         }
  72:         else {
  73:             LOGV("All seeks complete - return to regularly scheduled program");
  74:             mCurrentPosition = mSeekPosition = -1;
  75:         }
  76:         break;
  77:     case MEDIA_BUFFERING_UPDATE://缓冲
  78:         LOGV("buffering %d", ext1);
  79:         break;
  80:     case MEDIA_SET_VIDEO_SIZE://设置视频大小
  81:         LOGV("New video size %d x %d", ext1, ext2);
  82:         mVideoWidth = ext1;
  83:         mVideoHeight = ext2;
  84:         break;
  85:     default:
  86:         LOGV("unrecognized message: (%d, %d, %d)", msg, ext1, ext2);
  87:         break;
  88:     }
  89:  
  90:     sp<MediaPlayerListener> listener = mListener;
  91:     if (locked) mLock.unlock();
  92:  
  93:     // this prevents re-entrant calls into client code
  94:     if ((listener != 0) && send) {
  95:         Mutex::Autolock _l(mNotifyLock);
  96:         LOGV("callback application");
  97:         //调用监听器,回调应用的监听器
  98:         listener->notify(msg, ext1, ext2);
  99:         LOGV("back from callback");
 100:     }
 101: }

在监听器的notify方法中,是通过jni“反向调用”MediaPlayer.java中的postEventFromNative,在通过mEventHandler根据不同的消息类型调用不同的监听器。

   1: private static void postEventFromNative(Object mediaplayer_ref,
   2:                                           int what, int arg1, int arg2, Object obj)
   3:   {
   4:       MediaPlayer mp = (MediaPlayer)((WeakReference)mediaplayer_ref).get();
   5:       if (mp == null) {
   6:           return;
   7:       }
   8:  
   9:       if (mp.mEventHandler != null) {
  10:           Message m = mp.mEventHandler.obtainMessage(what, arg1, arg2, obj);
  11:           mp.mEventHandler.sendMessage(m);
  12:       }
  13:   }

OK,至此我们分析了seekTo的整个流程。其他方法的流程是很相似的,大家不妨亲自去看看。



最后的最后的最后,这是我第一次写博客,写得肯定很low,大家轻喷,我弱弱的护住了脸,今天是大年二十九,而我还在公司战(无)斗(聊)着,看着周围的同事都跑路了,默默地敲着代码,更气的是。像我这么敬(扯)业(淡)的员工居然没有敬业福,我想静静哭   
又到了立Flag(吹牛)的时候啦,2017年我希望自己能更加的充分利用时间,拒绝懒,远离浪费,快速地提升自己,能攻能守,方得始终,提前祝大家鸡年大吉吧大笑





源码地址------------------



另外我搞得SDK也上线了,主要是针对E01小胖机器人的,大家快去看看,赶紧来批斗我的不足!

SDK地址









Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐