前言 (闲聊)

之前在上移动平台开发课的过程中,对android的开发算是有一个大概的初步了解,但是知之甚浅。印象最深刻的就是但凡遇到图片视频方面的处理就会变得非常复杂以及容易出错。那时对于我这个小白来说想调用一个视频播放器来播放一小段视频都是一个"大"工程了,至于什么实时的视频对话想都不想去想,因为太复杂且麻烦!!!

但是有了功能齐全的SDK ,这次的实时视频开发,却是与以前完全不同的体验。直观感受就好像是我这种菜鸡做机器学习模型有了Python的sklearn库,菜鸡的大雄有了多啦A梦那样,拥有了一个万能的百宝箱。当你想实现一个想着就十分复杂的功能时例如直播的推拉流之类的,这里面就已经集成了对应的函数。

所用SDK介绍

关于SDK的安装本文不做过多描述,我使用的是ZEGO EXPRESS SDK,相应的安装详细过程请直接看链接

https://doc-zh.zego.im/zh/215.html,同时记得按照步骤申请对应的AppID以及AppSign.

使用此SDK的优点:代码简单易懂,文档内容较为全面,实现简单。

本文实际内容可以有些长,具体思路在目录中体现,也可以根据目录筛选查看内容

目录

一.所实现项目的功能

​ 1.项目实现核心截图

​ 2.所实现的功能

​ 3.适用的应用场景

二.实现流程

​ 1.布局的设计

​ 2.核心逻辑代码

​ 2.1推流拉流的概念

​ 2.2正式开始及全局变量的声明

​ 2.2.1 onCreate函数内操作

​ 1.申请AppID

​ 2.初始化SDK

​ 3.初始化用户及登录房间

​ 4.获取所在房间内的所有推流

​ 2.2.2 点击事件

​ 1.推拉流

​ 2.麦克风按钮处理

​ 3.与本地相机美颜、改变摄像头前后置等本地扩展功能

​ 4.退出按钮

三.源代码

四.不足以及可以继续开发的地方

一.所实现项目的功能

1. 核心视频UI代码截图

2.登录界面

2. 所实现的功能

​ (1).从登录界面到视频界面的跳转,以及传递所输入的房间号ID。以及在核心视频UI界面的退出到登录界面的跳转.

​ (2) 四人可同时正常视频通话,且对应每个流所呈现的画面进行打开关闭收音.

​ (3).实时的将同一房间号的正在推流的流ID全部显示在第二排视频下的TextView上(在该房间的用户可根据次项流ID内容)

​ (4).实现对本地界面中的前后置摄像头进行切换,美颜效果的实现。

3. 适用的应用场景

​ 家庭聊天,同事或者同学聊天以及简单的面对面会议。

二.实现流程

1. 先说简单说一下界面布局的设计。这个相对简单,看上述的界面也大概也能明白一些,说一下我做的过程中遇到的问题吧。

​ 第一个因为只需要输入房间ID,所以登陆界面很简单,线性布局垂直方向, 再加上TextView + EditText + Button就解决了的最简单登陆界面。实在不懂可以直接看第三部分的源代码。

​ 第二个界面对于新手来说还是有些困难的,首先由于四人视频所占空间很大,一个屏幕的大小不能轻易放下,这种需要滚动条的情况就可以采用三种手段来解决分别是RecyclerView,ListView以及ScrollView来解决。本文采用的是相对来说最简单的ScrollView来解决。这里就要注意了,ScrollView当中只有一个子元素,所以如果你想像我一样,把ScrollView作为最外层的话,需要在内部再嵌套一个线性布局,这样才能用。

相连视频的画面采用的是相对布局,这样更加方便做微调,视频本身使用TextureView来做。

我代码的整体设计布局框架如下(要注意的是本文对于核心视频UI的布局上图片的点击事件,都是在图片的属性中添加的android:onClick处理解决方法,这里看不懂没关系,后面也会说。)

<ScrollView><LinearLayout><RelativeLayout>自己本地视图和1号拉流视图</RelativeLayout><RelativeLayout>自己本地视图和1号拉流视图</RelativeLayout><TextView>当前登录房间的ID显示出来</TextView><TextView>本地ID显示出这几个字</TextView><LinearLayout>推流按钮和推流输入的ID在一起的布局,该线性布局为水平方向</LinearLayout>...(省略拉流1和2的TextView+LinearLayout的组合,写法类似下面这个框架的)<TextView>拉流3显示出来</TextView><LinearLayout>拉流3按钮和拉流3输入的ID在一起的布局,该线性布局也为水平方向</LinearLayout></LinearLayout>
</ScrollView>

更详尽的代码见第三部分的全部源码

2. 下面就是核心逻辑代码的实现了

注意:以下只展示核心的代码,并不能直接运行,具体操作请看第三部分。

2.1首先,如果不理解推流拉流的概念的话,首先要快速理解一下。

实际上我们可以用一个简单过程来帮助理解。

首先来说,推流就是你发送出去一串代码(流ID)以及你本地的照相机所拍摄的实时画面(所谓的流) 上网络且到达服务器并存储。且如果你不停止推流就要一直发送,就好像水流一样源源不断,但是服务器里面就好像是有门堵着的,水是不能轻易漏出来。

别人想在这个服务器上看你的视频咋办? 他就要拉你对应的流,咋拉呢?就通过你发的那个推出去的流ID来拉。如果他知道这个流ID ,就好像是找到了服务器对应一扇门的一把锁钥匙似的,把你不断发送到服务器的"流"大门打开,水流涌出来了一直到他的手机上,这样他就看到你的画面了。

而你如果停止了推流,水就没了,他自然就接收不到画面了。他停止拉流,等于是把之前那扇门又关上了,他手机上也不会再接收你的画面。

2.2理解之后,如果你的SDK已经按照最顶上链接集成完毕后,开发过程就可以正式开始了!

以下要用到很多的全局变量,首先展示一下他们的声明以及初始值,如果下面的有些看不懂的可以返回过来看一看这里所写的内容,以及注释。

 public static ZegoExpressEngine engine = null;boolean publishMicEnable = true; // 初始的自己麦克风为开着的boolean playStreamMute = true; //其余屏幕人的初始状态都为静音boolean playStreamMute2 = true;boolean playStreamMute3 = true;boolean isBeauty = false;//初始无美颜boolean isFrontCamera = true; // 初始为前置摄像头ImageButton ib_local_mic; //本地麦克风ImageButton ib_remote_stream_audio;//拉流1外部视角的音量ImageButton ib_remote_stream_audio2;//拉流2外部视角的音量ImageButton ib_remote_stream_audio3;//拉流3外部视角的音量ImageButton ib_beauty; //美颜按键String LocalStreamID; //本地推流IDString RemoteStreamID; //拉流1 IDString RemoteStreamID2; //拉流2 IDString RemoteStreamID3;//拉流3 IDArrayList<String> RoomStreamList;//所在登录的房间存在推流号private String userID;//用户IDString roomID;//房间ID//写好自己的ID和sign,以下为我所申请的ID,如果要自己使用或者商用请自行申请并修改long appID = ;  // 请通过官网注册获取,格式为 123456789LString appSign = "";  //64个字符,请通过官网注册获取,格式为"0123456789012345678901234567890123456789012345678901234567890123"

2.2.1 onCreate函数部分

(1). 申请AppID,这一步如果不做的话根本后面做不了!!所以要先申请一各APPID,可以看我最上方附的那个链接

(2) 根据你的appID以及appsign进行初始化SDK,使用测试环境,通用场景接入。如果这一步成功了,那么恭喜你,你已经获得了一个强大的神奇engine,他功能强大,后面所有所有都是依靠他来实现的,什么推流拉流就是一行代码的事情.

engine = ZegoExpressEngine.createEngine(appID, appSign, true, ZegoScenario.GENERAL, getApplication(), null);

(3). 初始化用户,并将用户登录至房间内。这步也是在进行视频通话之前的必须一步,我们每个人都是在服务器上一个独立的个体,想要实现特定用户群体之间的交流。房间是很好的一个工具。这个userid和name在全局中不能有任何重复,最好有一定意义,但我面向的场景主要是家庭场景,不太需要,如果是商用开发还是很有必要开发的。为了防止重复,我采用的是生成随机数,这样的话重复的概率就小很多了。

我这里的roomID,从登录界面时的intent的所传递的数据来定义的

//用户注册
String randomSuffix = String.valueOf(new Date().getTime() % (new Date().getTime() / 1000));
userID = "user" + randomSuffix;
ZegoUser user = new ZegoUser(userID);
...
//房间登录
ntent intent = getIntent();
roomID = intent.getStringExtra("room_id");//getXxxExtra方法获取Intent传递过来的roomIDengine.loginRoom(roomID, user);//有了房间号,将用户登录到该房间

(4). 获取所在房间内的所有推流。这里面主要就是要用到监听房间相关事件回调来实现主要用到的是回调中的onRoomStreamUpdate 要注意的是:这里的流更新指的是房间内其他用户的,用户自己的流产生变化,自己的这个回调函数是没有反应的。

想实现此功能,首先要创建一个ArrayList来记录房间内存在的所有的流ID。可以看到如下的RoomStreamList我是在全局变量的地方事先声明过了。(其他全局变量的声明我会放到后面函数部分说明) 这里放入的第一个元素的目的是为了方便在后面讲ArrayList中所有元素连接成为一行具体的内容.

RoomStreamList = new ArrayList<String>();
RoomStreamList.add("当前房间内的推流有:");

onRoomStreamUpdate的具体写法如下所示,看起来好像挺长,实际上思路就是如果有一个流ID状态发生改变,我就看我的列表当中是不是有这个流ID如果有那就去掉,如果没有就加入进去。实在不懂,根据注释也能看个大概,这里面需要提一下的是sentenceId += 这个并不是真正的加法,而是java中的字符串拼接,将ArrayList中所有元素拼成一句话,并找到要显示的TextView并显示出来。

engine.setEventHandler(new IZegoEventHandler() {...public void onRoomStreamUpdate(String roomID, ZegoUpdateType updateType, ArrayList<ZegoStream> streamList) {/* 流状态更新,登陆房间后,当房间内有用户新推送或删除音视频流时,SDK会通过该回调通知 *///自己的推流不会被记入for (int i = 0; i < streamList.size(); i++)//加入或退出房间流的所有推流id全都遍历一遍{Toast.makeText(getApplicationContext(), streamList.get(i).streamID + " room stream changed", Toast.LENGTH_LONG).show();if (RoomStreamList.contains(streamList.get(i).streamID)) {//如果现有列表中包含这个,就移除RoomStreamList.remove(streamList.get(i).streamID);} else {//如果现有列表中不包含这个就加入RoomStreamList.add(streamList.get(i).streamID);}}String SentenceId = "";// 用于记录下当前房间内还有的流IDfor (int i = 0; i < RoomStreamList.size(); i++) {SentenceId += RoomStreamList.get(i) + " ";//利用字符串拼接,将当前房间还在的所有流ID全部记下}TextView ViewIdlist = findViewById(R.id.stream_id_list);//找到用于显示流ID的TextViewViewIdlist.setText(SentenceId);//设置文字信息在TextView上体现出来}});

(5) 动态权限申请,代码如下。若需要申请更多权限则自行添加。

 String[] permissionNeeded = {"android.permission.CAMERA","android.permission.RECORD_AUDIO"};if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {if (ContextCompat.checkSelfPermission(this, "android.permission.CAMERA") != PackageManager.PERMISSION_GRANTED ||ContextCompat.checkSelfPermission(this, "android.permission.RECORD_AUDIO") != PackageManager.PERMISSION_GRANTED) {requestPermissions(permissionNeeded, 101);}}

2.2.2 接下来要做的就是逐步完成每个在视图中注册的点击事件,其中包括四个部分,分别是1.推拉流 2.麦克风按钮处理

3.与本地相机美颜、改变摄像头前后置等本地扩展功能 4.退出按钮

(1).推拉流

这也是视频通话最为主要的部分。但是却是十分简单的,核心的代码就只需要调用两个接口也就是两行代码就可以解决。但是还是有些要注意的事项。如下为推流核心代码,有所省略,具体实现详见第三部分。 实际上可以看出来,核心的逻辑就是判断推流按钮上的字是否是"推流",如果是的话就就行推流再把文字设置称为"停止推流"。

其中也包含了核心的接口就是startPublishingStream,stopPublishingStream以及startpreview和stoppreview来获取本地图像。

public void ClickPublish(View view) {...if (button.getText().equals("推流")) {//若上面的文字是推流,则说明还未推流。/* 开始推流 */EditText et = findViewById(R.id.ed_publish_stream_id);//找到,旁边的EditText的实例LocalStreamID = et.getText().toString();//获取其文字内容,并赋值给全局变量LocalStreamIDengine.startPublishingStream(LocalStreamID);//推流/* 开始预览并设置本地预览视图 *//* Start preview and set the local preview view. */View local_view = findViewById(R.id.local_view);//获取预览图像的TextureView实例engine.startPreview(new ZegoCanvas(local_view));//开始预览button.setText("停止推流");//文字从推流改变为停止推流} else {//若上面文字不是推流/* 停止推流 */engine.stopPublishingStream();//停止推流/* 停止本地预览 *//* Start stop preview */engine.stopPreview();//停止预览button.setText("推流");//文字变为推流}}

以拉流1按钮为例,拉流的实现实际上与推流十分类似甚至还简单一些。核心逻辑也是相似的判断按钮上的字是否为拉流1。核心的接口就是startPlayingStream和stopPlayingStream两个。

要注意的是,我这里首先对要拉的流默认是先静音的。这里的playStreamMute是一个全局变量默认值为True.

public void ClickPlay(View view) {...if (button.getText().equals("拉流1")) {//若文字为拉流1/* 开始拉流 *//* Begin to play stream */EditText et = findViewById(R.id.ed_play_stream_id);//获取拉流旁的EditText实例RemoteStreamID = et.getText().toString();//获取其字符串,作为1号拉流IDView play_view = findViewById(R.id.remote_view);//获取播放实例engine.startPlayingStream(RemoteStreamID, new ZegoCanvas(play_view));//开始拉流engine.mutePlayStreamAudio(RemoteStreamID, playStreamMute);//首先对各用户采取静音button.setText("停止拉流");//文字变为停止拉流} else {/* 停止拉流 *//* Begin to stop play stream */engine.stopPlayingStream(RemoteStreamID);//停止拉流button.setText("拉流1");//文字转变为拉流1}}

(2).麦克风按钮处理

首先来说本地话筒,如下的publishMicEnable是一个bool类型的全局变量,初始值为true,根据注释可以大概看懂。

要注意的就是核心接口engine.muteMicrophone(!publishMicEnable);这里括号中是对于publishMicEnable进行了取反的,原因可以根据变量的名字就能看出来,他muteMicrophone我们是publishMicEnable,二者意义本身就相反,所以取反之后才能体现原本意义。

    //本地麦克风public void enableLocalMic(View view) {publishMicEnable = !publishMicEnable;//将bool变量先取反,即状态改变if (publishMicEnable) {//本地麦克风经取反后为真,那么就把图标变为开启状态的图标ib_local_mic.setBackgroundDrawable(getResources().getDrawable(R.drawable.ic_bottom_microphone_on));} else {//反之,则变为关闭状态的图标ib_local_mic.setBackgroundDrawable(getResources().getDrawable(R.drawable.ic_bottom_microphone_off));}/* Enable Mic*/engine.muteMicrophone(!publishMicEnable);//因为这个函数是mute,而我们是enable,所以取反才与本义相同}

其次对于远端拉流麦克风的控制,以拉流1所收画面的麦克风为例。与本地麦克风相似,核心函数有所不同。此处的核心函数为engine.mutePlayStreamAudio(RemoteStreamID, playStreamMute),这里面的两个参数也都是全局变量,playStreamMute也是一个bool类型的变量,初始值为true。而这个RemoteStreamID这个全局变量在之前的拉流时所进行赋值的。

    public void enableRemoteMic(View view) {playStreamMute = !playStreamMute;//先将此bool变量取反,即状态改变if (playStreamMute) {//若此时该bool变量为真,则说明是静音状态,则图标变为关闭状态图标ib_remote_stream_audio.setBackgroundDrawable(getResources().getDrawable(R.drawable.ic_bottom_microphone_off));} else {//反之则变为开启状态ib_remote_stream_audio.setBackgroundDrawable(getResources().getDrawable(R.drawable.ic_bottom_microphone_on));}/* Enable Mic*/engine.mutePlayStreamAudio(RemoteStreamID, playStreamMute);//此处因为bool变量实际意义与函数本义相同,故不用取反}

(3).与本地相机美颜、改变摄像头前后置等本地扩展功能

这部分功能就比较简单了,实现的逻辑与麦克风相似,也是对于一个bool型全局变量进行判断。

相机美颜实现如下,核心的接口就是engine.enableBeautify(WHITEN);代码当中的isBeauty为一个全局变量的bool值。

这里我只使用了美白参数进行使用,还可以添加其他的参数,详情见顶部连接中点开API文档。

public void enableBeauty(View view) {isBeauty = !isBeauty;//取反//更换图标if (isBeauty) {//若现在为真,则变为使用美颜对应图标ib_beauty.setBackgroundDrawable(getResources().getDrawable(R.drawable.beauty_ps));} else {//若现在为假,则对应普通状态图标ib_beauty.setBackgroundDrawable(getResources().getDrawable(R.drawable.normal));}if (isBeauty) {//如果处于美颜状态//这里只采用一个较为明显的美白功能engine.enableBeautify(WHITEN);} else {//反之则关闭所有美颜设置engine.enableBeautify(NONE);}}

改变摄像头方向,因为不需要更换图标所以更为简单,核心接口是engine.useFrontCamera(isFrontCamera);其中isFront为bool类型的全局变量

    public void frontCamera(View view){isFrontCamera = !isFrontCamera;//先去反engine.useFrontCamera(isFrontCamera);//根据现有布尔值带入是否使用前置摄像头的函数中}

(4).退出按钮

这部分要注意的是,退出按钮要同时考虑到退出之后还要回到房间登录界面,以及若当前是推流状态要停止当前推流,以及退出用户对于房间的登录。实现起来还是比较简单的代码如下,其中使用了显示intent来进行活动的启动,roomID为一个全局变量。在初始化的主函数中就以及赋值为了从之前登录界面所带来的roomID.

    public void Logout(View view) {Intent intent = new Intent(this, Login.class);//设置一个从当前活动到Login活动的intentengine.stopPublishingStream();//停止推流engine.logoutRoom(roomID);//退出该房间startActivity(intent);//重新进入房间的登录界面finish();//结束当前活动}

这样就写完了,怎么样是不是非常简单!真正要自己去想的也就是一些逻辑的处理,大大节省了开发的时间。

三.源代码

1. 两组layout

登录界面layout

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"android:layout_width="match_parent"android:layout_height="match_parent"android:orientation="vertical"><LinearLayoutandroid:layout_width="match_parent"android:layout_height="557dp"android:gravity="center_vertical"android:orientation="vertical"><TextViewandroid:layout_width="match_parent"android:layout_height="wrap_content"android:text="请输入房间号"android:textSize="22dp"android:layout_gravity="center_horizontal"android:textAlignment="center"/><EditTextandroid:id="@+id/room_login"android:layout_width="match_parent"android:layout_height="wrap_content"android:layout_gravity="center_vertical" /><Buttonandroid:id="@+id/btn_login"android:layout_width="match_parent"android:layout_height="wrap_content"android:layout_gravity="center_vertical"android:text="登录"android:textSize="16dp"/></LinearLayout></LinearLayout>

核心视频UI的layout (有点长。。)

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"android:layout_width="match_parent"android:layout_height="match_parent"android:padding="1dp">
<LinearLayoutandroid:orientation="vertical"android:layout_width="match_parent"android:layout_height="wrap_content"><RelativeLayoutandroid:layout_width="match_parent"android:layout_height="273dp"android:background="#8D8B8B"android:orientation="horizontal"><Viewandroid:id="@+id/view"android:layout_width="3dp"android:layout_height="match_parent"android:layout_centerHorizontal="true"/><TextureViewandroid:id="@+id/local_view"android:layout_width="wrap_content"android:layout_height="match_parent"android:layout_marginBottom="3dp"android:layout_marginEnd="4dp"android:layout_marginLeft="3dp"android:layout_marginRight="4dp"android:layout_marginStart="3dp"android:layout_marginTop="3dp"android:layout_toLeftOf="@id/view"android:layout_toStartOf="@id/view" /><TextViewandroid:id="@+id/textView"android:layout_width="match_parent"android:layout_height="33dp"android:layout_marginEnd="0dp"android:layout_marginRight="0dp"android:layout_toLeftOf="@id/view"android:layout_toStartOf="@id/view"android:gravity="center"android:text="LOCAL"android:textColor="#ffffff"/><ImageButtonandroid:id="@+id/ib_local_camera_change"android:layout_width="33dp"android:layout_height="33dp"android:layout_alignParentBottom="true"android:layout_marginRight="77dp"android:layout_marginBottom="7dp"android:layout_toStartOf="@id/view"android:layout_toLeftOf="@id/view"android:background="@drawable/arrow"android:onClick="frontCamera" /><ImageButtonandroid:id="@+id/ib_local_beauti"android:layout_width="33dp"android:layout_height="33dp"android:layout_alignParentBottom="true"android:layout_marginRight="42dp"android:layout_marginBottom="7dp"android:layout_toStartOf="@id/view"android:layout_toLeftOf="@id/view"android:background="@drawable/normal"android:onClick="enableBeauty" /><ImageButtonandroid:id="@+id/ib_local_mic"android:layout_width="33dp"android:layout_height="33dp"android:layout_alignParentBottom="true"android:layout_marginRight="7dp"android:layout_marginBottom="7dp"android:layout_toStartOf="@id/view"android:layout_toLeftOf="@id/view"android:background="@drawable/ic_bottom_microphone_on"android:onClick="enableLocalMic" /><TextureViewandroid:id="@+id/remote_view"android:layout_width="wrap_content"android:layout_height="match_parent"android:layout_marginBottom="3dp"android:layout_marginEnd="3dp"android:layout_marginLeft="6dp"android:layout_marginRight="3dp"android:layout_marginStart="6dp"android:layout_marginTop="3dp"android:layout_toEndOf="@id/view"android:layout_toRightOf="@id/view" /><TextViewandroid:id="@+id/textView2"android:layout_width="match_parent"android:layout_height="33dp"android:layout_toEndOf="@id/view"android:layout_toRightOf="@id/view"android:gravity="center"android:text="REMOTE"android:textColor="#ffffff" /><ImageButtonandroid:id="@+id/ib_remote_mic"android:layout_width="33dp"android:layout_height="33dp"android:layout_alignParentRight="true"android:layout_alignParentBottom="true"android:layout_marginEnd="7dp"android:layout_marginRight="7dp"android:layout_marginBottom="7dp"android:background="@drawable/ic_bottom_microphone_off"android:onClick="enableRemoteMic" /></RelativeLayout><RelativeLayoutandroid:layout_width="match_parent"android:layout_height="273dp"android:background="#8D8B8B"android:orientation="horizontal"><Viewandroid:id="@+id/view2"android:layout_width="3dp"android:layout_height="match_parent"android:layout_centerHorizontal="true"/><TextureViewandroid:id="@+id/remote_view2"android:layout_width="wrap_content"android:layout_height="match_parent"android:layout_marginBottom="3dp"android:layout_marginEnd="5dp"android:layout_marginLeft="3dp"android:layout_marginRight="5dp"android:layout_marginStart="3dp"android:layout_marginTop="3dp"android:layout_toLeftOf="@id/view2"android:layout_toStartOf="@id/view2" /><TextViewandroid:id="@+id/textView3"android:layout_width="match_parent"android:layout_height="33dp"android:layout_marginEnd="0dp"android:layout_marginRight="0dp"android:layout_toLeftOf="@id/view2"android:layout_toStartOf="@id/view2"android:gravity="center"android:text="REMOTE2"android:textColor="#ffffff"/><ImageButtonandroid:id="@+id/ib_remote_mic2"android:layout_width="33dp"android:layout_height="33dp"android:layout_alignParentBottom="true"android:layout_marginRight="7dp"android:layout_marginBottom="7dp"android:layout_toStartOf="@id/view2"android:layout_toLeftOf="@id/view2"android:background="@drawable/ic_bottom_microphone_off"android:onClick="enableRemoteMic2" /><TextureViewandroid:id="@+id/remote_view3"android:layout_width="wrap_content"android:layout_height="match_parent"android:layout_marginBottom="3dp"android:layout_marginEnd="3dp"android:layout_marginLeft="0dp"android:layout_marginRight="3dp"android:layout_marginStart="0dp"android:layout_marginTop="3dp"android:layout_toEndOf="@id/view2"android:layout_toRightOf="@id/view2" /><TextViewandroid:id="@+id/textView4"android:layout_width="match_parent"android:layout_height="33dp"android:layout_toEndOf="@id/view2"android:layout_toRightOf="@id/view2"android:gravity="center"android:text="REMOTE3"android:textColor="#ffffff" /><ImageButtonandroid:id="@+id/ib_remote_mic3"android:layout_width="33dp"android:layout_height="33dp"android:layout_alignParentRight="true"android:layout_alignParentBottom="true"android:layout_marginEnd="7dp"android:layout_marginRight="7dp"android:layout_marginBottom="7dp"android:background="@drawable/ic_bottom_microphone_off"android:onClick="enableRemoteMic3" /></RelativeLayout><TextViewandroid:id="@+id/stream_id_list"android:layout_width="match_parent"android:layout_height="wrap_content"android:text="当前房间内的推流有:"android:textSize="15dp"/><TextViewandroid:layout_width="match_parent"android:layout_height="wrap_content"android:text="本地ID"android:layout_gravity="center_horizontal"android:textAlignment="center"/><LinearLayoutandroid:layout_width="wrap_content"android:layout_height="wrap_content"><Buttonandroid:id="@+id/btn_start_publish"android:layout_width="152dp"android:layout_height="wrap_content"android:onClick="ClickPublish"android:text="推流" /><EditTextandroid:id="@+id/ed_publish_stream_id"android:layout_width="275dp"android:layout_height="match_parent"></EditText></LinearLayout><TextViewandroid:layout_width="match_parent"android:layout_height="wrap_content"android:text="一号远端ID"android:layout_gravity="center_horizontal"android:textAlignment="center"/><LinearLayoutandroid:layout_width="wrap_content"android:layout_height="wrap_content"><Buttonandroid:id="@+id/btn_start_play"android:layout_width="152dp"android:layout_height="wrap_content"android:text="拉流1"android:onClick="ClickPlay"/><EditTextandroid:id="@+id/ed_play_stream_id"android:layout_width="275dp"android:layout_height="match_parent"></EditText></LinearLayout><TextViewandroid:layout_width="match_parent"android:layout_height="wrap_content"android:text="二号远端ID"android:layout_gravity="center_horizontal"android:textAlignment="center"/><LinearLayoutandroid:layout_width="wrap_content"android:layout_height="wrap_content"><Buttonandroid:id="@+id/btn_start_play2"android:layout_width="152dp"android:layout_height="wrap_content"android:text="拉流2"android:onClick="ClickPlay2"/><EditTextandroid:id="@+id/ed_play_stream_id2"android:layout_width="275dp"android:layout_height="match_parent"></EditText></LinearLayout><TextViewandroid:layout_width="match_parent"android:layout_height="wrap_content"android:text="三号远端ID"android:layout_gravity="center_horizontal"android:textAlignment="center"/><LinearLayoutandroid:layout_width="wrap_content"android:layout_height="wrap_content"><Buttonandroid:id="@+id/btn_start_play3"android:layout_width="152dp"android:layout_height="wrap_content"android:text="拉流3"android:onClick="ClickPlay3"/><EditTextandroid:id="@+id/ed_play_stream_id3"android:layout_width="275dp"android:layout_height="match_parent"></EditText></LinearLayout><Buttonandroid:id="@+id/btn_logout"android:layout_width="match_parent"android:layout_height="wrap_content"android:onClick="Logout"android:text="退出房间" /></LinearLayout>
</ScrollView>

2. 活动java源代码

登录界面.java

import androidx.appcompat.app.AppCompatActivity;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;public class Login extends AppCompatActivity {protected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_login);//设置布局Button login = findViewById(R.id.btn_login);//获取登录按钮实例login.setOnClickListener(new View.OnClickListener() {@Overridepublic void onClick(View v) {//匿名类实现监听功能EditText roomIDx = findViewById(R.id.room_login);//获取用于输入的EditText的实例String roomID = roomIDx.getText().toString().trim();//获取其中的文字,也就是对应的roomIDif (roomID.equals("")) {//检查此ID是否为空,为空则弹出,请输入信息。Toast.makeText(Login.this, "请输入roomID", Toast.LENGTH_LONG).show();}else {//反之启动活动UIIntent intent =new Intent(Login.this, UI.class);//创建一个显式intentintent.putExtra("room_id", roomID);//并将房间号作为夸活动传输的数据传输到UI活动当中startActivity(intent);//启动Activityfinish();//结束活动}}});}
}

核心视频UI.java

import android.content.pm.PackageManager;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import im.zego.zegoexpress.ZegoExpressEngine;
import im.zego.zegoexpress.constants.ZegoRoomState;
import im.zego.zegoexpress.constants.ZegoUpdateType;
import im.zego.zegoexpress.entity.ZegoCanvas;
import im.zego.zegoexpress.entity.ZegoStream;
import im.zego.zegoexpress.entity.ZegoUser;
import im.zego.zegoexpress.callback.IZegoEventHandler;
import im.zego.zegoexpress.constants.ZegoScenario;import android.content.Intent;
import android.os.Build;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.TextView;
import android.widget.Toast;// 导入对应美颜参数的常量值
import static im.zego.zegoexpress.constants.ZegoBeautifyFeature.*;import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Date;public class UI extends AppCompatActivity {public static ZegoExpressEngine engine = null;boolean publishMicEnable = true; // 初始的自己麦克风为开着的boolean playStreamMute = true; //其余屏幕人的初始状态都为静音boolean playStreamMute2 = true;boolean playStreamMute3 = true;boolean isBeauty = false;//初始无美颜boolean isFrontCamera = true; // 初始为前置摄像头ImageButton ib_local_mic; //本地麦克风ImageButton ib_remote_stream_audio;//拉流1外部视角的音量ImageButton ib_remote_stream_audio2;//拉流2外部视角的音量ImageButton ib_remote_stream_audio3;//拉流3外部视角的音量ImageButton ib_beauty; //美颜按键String LocalStreamID; //本地推流IDString RemoteStreamID; //拉流1 IDString RemoteStreamID2; //拉流2 IDString RemoteStreamID3;//拉流3 IDArrayList<String> RoomStreamList;//所在登录的房间存在推流号private String userID;//用户IDString roomID;//房间ID//写好自己的ID和sign,以下为我所申请的ID,如果要自己使用或者商用请自行申请并修改long appID = ;  // 请通过官网注册获取,格式为 123456789LString appSign = ;  //64个字符,请通过官网注册获取,格式为"0123456789012345678901234567890123456789012345678901234567890123"protected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);/* 填写 appID 和 appSign *//* 初始化SDK,使用测试环境,通用场景接入,此为自动初始化,无需点击按钮*/engine = ZegoExpressEngine.createEngine(appID, appSign, true, ZegoScenario.GENERAL, getApplication(), null);setContentView(R.layout.activity_main);//登录/* 创建用户 *//* 生成随机的用户ID,避免不同手机使用时用户ID冲突,相互影响 *//* Generate random user ID to avoid user ID conflict and mutual influence when different mobile phones are used */String randomSuffix = String.valueOf(new Date().getTime() % (new Date().getTime() / 1000));userID = "user" + randomSuffix;ZegoUser user = new ZegoUser(userID);//初始化房间内流id数组RoomStreamList = new ArrayList<String>();RoomStreamList.add("当前房间内的推流有:");/* 开始登陆房间 *///房间状态改变,时间处理engine.setEventHandler(new IZegoEventHandler() {/** 以下为常用的房间相关回调 */public void onRoomStateUpdate(String roomID, ZegoRoomState state, int errorCode, JSONObject extendedData) {//房间状态改变,提示信息Toast.makeText(getApplicationContext(), "room state changed", Toast.LENGTH_SHORT).show();}public void onRoomUserUpdate(String roomID, ZegoUpdateType updateType, ArrayList<ZegoUser> userList) {/* 用户状态更新,登陆房间后,当房间内有用户新增或删除时,SDK会通过该回调通知 *///....//用户加入提示信息Toast.makeText(getApplicationContext(), userList.get(0) + "加入房间", Toast.LENGTH_LONG).show();}public void onRoomStreamUpdate(String roomID, ZegoUpdateType updateType, ArrayList<ZegoStream> streamList) {/* 流状态更新,登陆房间后,当房间内有用户新推送或删除音视频流时,SDK会通过该回调通知 *///自己的推流不会被记入for (int i = 0; i < streamList.size(); i++)//加入或退出房间流的所有推流id全都遍历一遍{Toast.makeText(getApplicationContext(), streamList.get(i).streamID + " room stream changed", Toast.LENGTH_LONG).show();if (RoomStreamList.contains(streamList.get(i).streamID)) {//如果现有列表中包含这个,就移除RoomStreamList.remove(streamList.get(i).streamID);} else {//如果现有列表中不包含这个就加入RoomStreamList.add(streamList.get(i).streamID);}}String SentenceId = "";// 用于记录下当前房间内还有的流IDfor (int i = 0; i < RoomStreamList.size(); i++) {SentenceId += RoomStreamList.get(i) + " ";//利用字符串拼接,将当前房间还在的所有流ID全部记下}TextView ViewIdlist = findViewById(R.id.stream_id_list);//找到用于显示流ID的TextViewViewIdlist.setText(SentenceId);//设置文字信息在TextView上体现出来}});//房间ID为Login活动传递过来的Intent intent = getIntent();roomID = intent.getStringExtra("room_id");//getXxxExtra方法获取Intent传递过来的roomIDengine.loginRoom(roomID, user);//有了房间号,将用户登录到该房间// 麦克风ib_local_mic = findViewById(R.id.ib_local_mic);//找到本地麦克风图标/* 音频播放是否静音的开关 *//* Switch for mute audio output */ib_remote_stream_audio = findViewById(R.id.ib_remote_mic);//找到拉流1麦克风图标并赋值给之前定义的全局变量ib_remote_stream_audio2 = findViewById(R.id.ib_remote_mic2);//找到拉流2麦克风图标并赋值给之前定义的全局变量ib_remote_stream_audio3 = findViewById(R.id.ib_remote_mic3);//找到拉流3麦克风图标并赋值给之前定义的全局变量ib_beauty = findViewById(R.id.ib_local_beauti);//找到美颜图标//动态申请权限String[] permissionNeeded = {"android.permission.CAMERA","android.permission.RECORD_AUDIO"};if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {if (ContextCompat.checkSelfPermission(this, "android.permission.CAMERA") != PackageManager.PERMISSION_GRANTED ||ContextCompat.checkSelfPermission(this, "android.permission.RECORD_AUDIO") != PackageManager.PERMISSION_GRANTED) {requestPermissions(permissionNeeded, 101);}}}// Part I 推拉流按钮处理/*点击推流按钮进行推流 *//*Click Publish Button*/public void ClickPublish(View view) {if (engine == null) {//之前自动初始化的SDK若未初始化成功则会弹出以下内容Toast.makeText(this, "please initiate your sdk first!", Toast.LENGTH_SHORT).show();return;}Button button = (Button) view;//获取推流这个按钮的实例if (button.getText().equals("推流")) {//若上面的文字是推流,则说明还未推流。EditText et = findViewById(R.id.ed_publish_stream_id);//找到,旁边的EditText的实例LocalStreamID = et.getText().toString();//获取其文字内容,并赋值给全局变量LocalStreamID/* 开始推流 *//* Begin to publish stream */engine.startPublishingStream(LocalStreamID);//推流Toast.makeText(this, "published", Toast.LENGTH_SHORT).show();//推流成功文字提示/* 开始预览并设置本地预览视图 *//* Start preview and set the local preview view. */View local_view = findViewById(R.id.local_view);//获取预览图像的TextureView实例engine.startPreview(new ZegoCanvas(local_view));//开始预览Toast.makeText(this, "preview is set", Toast.LENGTH_SHORT).show();//提示预览设置成功button.setText("停止推流");//文字从推流改变为停止推流} else {//若上面文字不是推流/* 停止推流 *//* Begin to stop publish stream */engine.stopPublishingStream();//停止推流/* 停止本地预览 *//* Start stop preview */engine.stopPreview();//停止预览Toast.makeText(this, "publishing has stopped", Toast.LENGTH_SHORT).show();//提示停止已成功button.setText("推流");//文字变为推流}}/* 点击拉流1按钮*//*Click Play Button*///由于如下三个按钮,实现代码大同小异所以就只 详写 此按钮注释,其他实现原理一致public void ClickPlay(View view) {if (engine == null) {//之前自动初始化的SDK若未初始化成功则会弹出以下内容Toast.makeText(this, "please initiate your sdk first!", Toast.LENGTH_SHORT).show();return;}Button button = (Button) view;//获取按钮实例if (button.getText().equals("拉流1")) {//若文字为拉流1EditText et = findViewById(R.id.ed_play_stream_id);//获取拉流旁的EditText实例RemoteStreamID = et.getText().toString();//获取其字符串,作为1号拉流ID/* 开始拉流 *//* Begin to play stream */View play_view = findViewById(R.id.remote_view);//获取播放实例engine.startPlayingStream(RemoteStreamID, new ZegoCanvas(play_view));//开始拉流engine.mutePlayStreamAudio(RemoteStreamID, playStreamMute);//首先对各用户采取静音Toast.makeText(this, "Remote1 played successfully", Toast.LENGTH_SHORT).show();//提示拉流画面播放成功button.setText("停止拉流");//文字变为停止拉流} else {/* 停止拉流 *//* Begin to stop play stream */engine.stopPlayingStream(RemoteStreamID);//停止拉流Toast.makeText(this, "Remote1 stopped successfully", Toast.LENGTH_SHORT).show();//提示停止拉流成功button.setText("拉流1");//文字转变为拉流1}}/* 点击拉流2按钮 *//*Click Play Button*///与拉流1按钮相似public void ClickPlay2(View view) {if (engine == null) {//之前自动初始化的SDK若未初始化成功则会弹出以下内容Toast.makeText(this, "please initiate your sdk first!", Toast.LENGTH_SHORT).show();return;}Button button = (Button) view;if (button.getText().equals("拉流2")) {EditText et = findViewById(R.id.ed_play_stream_id2);RemoteStreamID2 = et.getText().toString();/* 开始拉流 *//* Begin to play stream */View play_view = findViewById(R.id.remote_view2);engine.startPlayingStream(RemoteStreamID2, new ZegoCanvas(play_view));engine.mutePlayStreamAudio(RemoteStreamID2, playStreamMute2);Toast.makeText(this, "Remote2 played successfully", Toast.LENGTH_SHORT).show();button.setText("停止拉流");} else {/* 停止拉流 *//* Begin to stop play stream */engine.stopPlayingStream(RemoteStreamID2);Toast.makeText(this, "Remote2 stopped successfully", Toast.LENGTH_SHORT).show();button.setText("拉流2");}}/* 点击拉流按钮3 *//*Click Play Button*///与拉流1按钮相似public void ClickPlay3(View view) {if (engine == null) {//之前自动初始化的SDK若未初始化成功则会弹出以下内容Toast.makeText(this, "please initiate your sdk first!", Toast.LENGTH_SHORT).show();return;}Button button = (Button) view;if (button.getText().equals("拉流3")) {EditText et = findViewById(R.id.ed_play_stream_id3);RemoteStreamID3 = et.getText().toString();View play_view = findViewById(R.id.remote_view3);/* 开始拉流 *//* Begin to play stream */engine.startPlayingStream(RemoteStreamID3, new ZegoCanvas(play_view));engine.mutePlayStreamAudio(RemoteStreamID3, playStreamMute3);Toast.makeText(this, "Remote3 played successfully", Toast.LENGTH_SHORT).show();button.setText("停止拉流");} else {/* 停止拉流 *//* Begin to stop play stream */EditText et = findViewById(R.id.ed_play_stream_id3);engine.stopPlayingStream(RemoteStreamID3);Toast.makeText(this, "Remote3 stopped successfully", Toast.LENGTH_SHORT).show();button.setText("拉流3");}}// Part II 麦克风按钮处理//本地麦克风public void enableLocalMic(View view) {if (engine == null) {//之前自动初始化的SDK若未初始化成功则会弹出以下内容Toast.makeText(this, "please initiate your sdk first!", Toast.LENGTH_SHORT).show();return;}publishMicEnable = !publishMicEnable;//将bool变量先取反,即状态改变if (publishMicEnable) {//本地麦克风经取反后为真,那么就把图标变为开启状态的图标ib_local_mic.setBackgroundDrawable(getResources().getDrawable(R.drawable.ic_bottom_microphone_on));} else {//反之,则变为关闭状态的图标ib_local_mic.setBackgroundDrawable(getResources().getDrawable(R.drawable.ic_bottom_microphone_off));}/* Enable Mic*/engine.muteMicrophone(!publishMicEnable);//因为这个函数是mute,而我们是enable,所以取反才与本义相同}//一号拉流麦克风处理,二三号也大同小异public void enableRemoteMic(View view) {if (engine == null) {//之前自动初始化的SDK若未初始化成功则会弹出以下内容Toast.makeText(this, "please initiate your sdk first!", Toast.LENGTH_SHORT).show();return;}playStreamMute = !playStreamMute;//先将此bool变量取反,即状态改变if (playStreamMute) {//若此时该bool变量为真,则说明是静音状态,则图标变为关闭状态图标ib_remote_stream_audio.setBackgroundDrawable(getResources().getDrawable(R.drawable.ic_bottom_microphone_off));} else {//反之则变为开启状态ib_remote_stream_audio.setBackgroundDrawable(getResources().getDrawable(R.drawable.ic_bottom_microphone_on));}/* Enable Mic*/engine.mutePlayStreamAudio(RemoteStreamID, playStreamMute);//此处因为bool变量实际意义与函数本义相同,故不用取反}//二号拉流麦克风处理,与一号类似public void enableRemoteMic2(View view) {if (engine == null) {//之前自动初始化的SDK若未初始化成功则会弹出以下内容Toast.makeText(this, "please initiate your sdk first!", Toast.LENGTH_SHORT).show();return;}playStreamMute2 = !playStreamMute2;if (playStreamMute2) {ib_remote_stream_audio2.setBackgroundDrawable(getResources().getDrawable(R.drawable.ic_bottom_microphone_off));} else {ib_remote_stream_audio2.setBackgroundDrawable(getResources().getDrawable(R.drawable.ic_bottom_microphone_on));}/* Enable Mic*/engine.mutePlayStreamAudio(RemoteStreamID2, playStreamMute2);}//三号拉流麦克风处理,与一号类似public void enableRemoteMic3(View view) {if (engine == null) {//之前自动初始化的SDK若未初始化成功则会弹出以下内容Toast.makeText(this, "please initiate your sdk first!", Toast.LENGTH_SHORT).show();return;}playStreamMute3 = !playStreamMute3;if (playStreamMute3) {ib_remote_stream_audio3.setBackgroundDrawable(getResources().getDrawable(R.drawable.ic_bottom_microphone_off));} else {ib_remote_stream_audio3.setBackgroundDrawable(getResources().getDrawable(R.drawable.ic_bottom_microphone_on));}/* Enable Mic*/engine.mutePlayStreamAudio(RemoteStreamID3, playStreamMute3);}// Part III 本地相机美颜、改变摄像头前后置等扩展功能//美颜功能public void enableBeauty(View view) {if (engine == null) {//之前自动初始化的SDK若未初始化成功则会弹出以下内容Toast.makeText(this, "please initiate your sdk first!", Toast.LENGTH_SHORT).show();return;}isBeauty = !isBeauty;//取反//更换图标if (isBeauty) {//若现在为真,则变为使用美颜对应图标ib_beauty.setBackgroundDrawable(getResources().getDrawable(R.drawable.beauty_ps));} else {//若现在为假,则对应普通状态图标ib_beauty.setBackgroundDrawable(getResources().getDrawable(R.drawable.normal));}if (isBeauty) {//如果处于美颜状态//这里只采用一个较为明显的美白功能engine.enableBeautify(WHITEN);} else {//反之则关闭所有美颜设置engine.enableBeautify(NONE);}}//调用后置摄像头public void frontCamera(View view){if (engine == null) {//之前自动初始化的SDK若未初始化成功则会弹出以下内容Toast.makeText(this, "please initiate your sdk first!", Toast.LENGTH_SHORT).show();return;}isFrontCamera = !isFrontCamera;//先去反engine.useFrontCamera(isFrontCamera);//根据现有布尔值带入是否使用前置摄像头的函数中}// Part IV 退出按钮public void Logout(View view) {Intent intent = new Intent(this, Login.class);//设置一个从当前活动到Login活动的intentengine.stopPublishingStream();//停止推流engine.logoutRoom(roomID);//退出该房间startActivity(intent);//重新进入房间的登录界面finish();//结束当前活动}
}

四.功能上的不足以及可以继续开发的地方

1.为了更加方便人的使用,可以将对应房间的推流id自动进行拉流。

2.扩展功能可以增加一些其他其他的,类似像是只听见其他用户的声音关闭其画面

3.利用其他技术,实现手机端的屏幕共享。

4.可以通过RecyclerView等或其他方式,从而实现房间内一个用户界面能显示更多画面,使得最多拉的流数>3

5.当前拉的某个流停止时,画面静止,可以考虑让其转换为黑屏。

ZEGO EXPRESS SDK轻松实现Android端四人视频聊天相关推荐

  1. Android 集成 Agora SDK 快速体验 RTC 版多人视频聊天|掘金技术征文

    RTC (Real-Time Communication) 作为实时通讯领域的"新贵",在互动直播.远程控制.多人视频会议.屏幕共享等领域广受好评,如果你还不了解 RTC ,Tak ...

  2. ZEGO Flutter SDK 助力开发者高效实现跨平台音视频功能

    近日,即构科技SDK新增支持Flutter跨平台移动框架的方式接入,开发者基于ZEGO Flutter SDK可简单高效地实现跨平台音视频的功能. 一. 什么是Flutter Flutter是Goog ...

  3. Android端实时音视频开发指南

    简介 yun2win-sdk-Android提供Android端实时音视频完整解决方案,方便客户快速集成实时音视频功能. SDK 提供的能力如下: 发起 加入 AVClient Channel AVM ...

  4. Android多人视频聊天应用的开发(三)多人聊天

    在上一篇<Android多人视频聊天应用的开发(二)一对一聊天>中我们学习了如何使用声网Agora SDK进行一对一的聊天,本篇主要讨论如何使用Agora SDK进行多人聊天.主要需要实现 ...

  5. Android多人视频聊天应用的开发(二)一对一聊天

    在上一篇<Android多人视频聊天应用的开发(一)快速集成>中我们讨论了如何配置Agora Android SDK,本文我们将探索使用Agora进行一对一视频聊天的奥秘. 鉴权 APP ...

  6. 短视频技术详解:Android端的短视频开发技术

    在 <如何快速实现移动端短视频功能?>中,我们主要介绍了当前短视频的大热趋势以及开发一个短视频应用所涉及到的功能和业务.在本篇文章中,我们主要谈一谈短视频在Android端上的具体实现技术 ...

  7. Android端的短视频开发技术

    在上一篇 <快速实现移动端短视频功能?没你想得那么难!>文章中,我们主要介绍了当前短视频的大热趋势以及开发一个短视频应用所涉及到的功能和业务.在这篇文章中,我们主要谈一谈短视频在Andro ...

  8. Android多人视频聊天应用的开发(一)快速集成

    自从2016年,鼓吹"互联网寒冬"的论调甚嚣尘上,2017年亦有愈演愈烈之势.但连麦直播.在线抓娃娃.直播问答.远程狼人杀等类型的项目却异军突起,成了投资人的风口,创业者的蓝海和用 ...

  9. android 使用WebRTC搭建视频聊天室

    使用WebRTC搭建前端视频聊天室--入门篇 https://www.jianshu.com/p/b54b27970534 android webrtc 两个手机 P2P 视频聊天 https://w ...

最新文章

  1. 2、安装ICS(Internet Component Suite)控件
  2. c++引用另一个类的方法_VlookUp函数使用方法,一张表引用另一张表的数据。
  3. 微软称不放弃收购雅虎
  4. flask中的request
  5. RabbitMQ ——“Hello World”
  6. 升级到win10,安装visualstudio ,80端口被系统服务占用的解决
  7. 项目--------------使用BiLSTMCRF将病例文本中的诊断数据识别出来
  8. 东南亚支付——柬埔寨行
  9. luogu 4768
  10. fidde调试手机_实操:手机上用Fiddler调试页面(嘎)
  11. keepalived和heartbeat区别
  12. 刘敏华:2013年网络营销行业展望
  13. 【毕业设计】jsp+sql毕业选题系统(论文)
  14. unix支持哪些原始文件系统操作_UNIX环境高级程序设计(APUE)第4章第1部分:文件系统基础知识...
  15. 网站性能测试工具 webbench 的安装和使用-linux
  16. RainMeter学习1
  17. RF无线射频电路设计难点分析
  18. MICRO USB引脚定义以及接法
  19. 您的私人助理已上线:保护重要数据的101条建议
  20. ISTQB中的测试条件是什么?和测试用例的前置条件有什么区别?

热门文章

  1. 文件存取信息(c++)
  2. 数字图像处理5--边缘检测探究(内容较多,持续更新)
  3. 每日一面 - sqrt (2)约等于 1.414,如何求sqrt (2)小数点后 10 位
  4. OpenStack 入门体验
  5. 基于python Django 餐馆点菜管理系统
  6. A-1077 Kuchiguse (20 分)
  7. python 桌面截图opencv显示的三种方式比较,及c++ 桌面截图源码
  8. Java期末考试复习知识点总结
  9. 【Canvas】js用canvas绘制一个钟表时钟动画效果
  10. 第一次参赛获Java B组国二,给蓝桥杯Beginners的6700字保姆级经验分享。