简述

首先是摄像头问题,很多菜鸟比如我会纠结使用那种摄像头,我觉得4G图传CSI就可以了,USB摄像头似乎消耗系统资源会更多,而且在广域网图传受4G速度影响不可能太清晰,即分辨率高的摄像头反而鸡肋。这里有个小坑,绝大部分摄像头支持MJPEG、YUY2、YUV等格式,乍一看还以为支持硬编码,其实MJPEG是拿不到的,即便是能拿到也只是软编码,比如ESP32-CAM我猜就是软编码,硬编码的速度不应该那么慢。

MJPEG

使用MJPEG图传很方便,看一下格式:

MJPEG每帧都是关键帧,也不需要解码,上位机基本不需要对它做处理,但是这货有个问题,超过SVGA也就是800X600,编码出来的大小在网络上传输是很不理想的,如果每帧超过上百KB了,估计5G才能满足这个需求了,YUY2、YUV就更不适合在网络上传输了,单帧已经过MB了。如果对清晰度要求不是很高,SVGA就已经可以满足需求了,只不过费点流量,也很容易卡帧。
在python中获取JPG很容易,对JPG的质量也可以方便的控制

if(self.cap.isOpened()):ret, frame = self.cap.read()else:time.sleep(1)continuetry:data = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])[1]self.client.sendall(data.tobytes())except Exception as e:print(e)

这里我试过480X320的分辨率还是很流畅的,而800X600在网络条件很不错的情况下也是可以的,但是4G网络速度变化太不稳定了,所以即便是800X600也感觉很奢侈了,主要是cv2.imencode的编码速度瓶颈,我也尝试了其它工具(https://blog.csdn.net/weixin_43928944/article/details/111245235),结论也是一样的,软编码800X600是个瓶颈。

h264

想高清图传还是需要用目前资料比较多的H264方便一些。

这里I帧是关键帧,补偿帧(P/B帧),SPS(Sequence Parameter Set:序列参数集)和PPS(Picture Parameter Set:图像参数集)
编码后的h264每一帧都是以0001或者001开头的,0001居多,因为我是用TCP传的,所以这个头就很容易识别每条编码了。
python 编码h264方式很多也很方便这里就不提了,服务器负责转发没什么好说的,注意一下缓冲区别太小导致丢失数据即可

 //创建ServerBootstrap实例ServerBootstrap serverBootstrap = new ServerBootstrap();//初始化ServerBootstrap的线程组serverBootstrap.group(bossGroup, workerGroup).option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000).option(ChannelOption.RCVBUF_ALLOCATOR, new FixedRecvByteBufAllocator(65535));//设置将要被实例化的ServerChannel类serverBootstrap.channel(NioServerSocketChannel.class);////在ServerChannelInitializer中初始化ChannelPipeline责任链,并添加到serverBootstrap中serverBootstrap.childHandler(new ImageChannelInitializer());//标识当服务器请求处理线程全满时,用于临时存放已完成三次握手的请求的队列的最大长度serverBootstrap.option(ChannelOption.SO_BACKLOG, 1024);// 是否启用心跳保活机机制serverBootstrap.childOption(ChannelOption.SO_KEEPALIVE, true);//收发缓冲区serverBootstrap.childOption(ChannelOption.SO_RCVBUF,400*1024);serverBootstrap.childOption(ChannelOption.SO_SNDBUF,400*1024);

h264解码是比较麻烦的,调试起来参数也比较多,我参考的是这位大佬https://blog.csdn.net/qq_36467463/article/details/77977562
这位大佬参考的是那位大神http://www.itdadao.com/articles/c15a280703p0.html
Android的布局主流一般是TextureView、或是SurfaceView,这里没有深入研究区别,我用的SurfaceView
贴一个使用TextureView的参考代码:

package com.marchnetworks.decodeh264;import android.graphics.SurfaceTexture;
import android.media.MediaCodec;
import android.media.MediaFormat;
import android.os.AsyncTask;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.Surface;
import android.view.TextureView;import java.nio.ByteBuffer;public class MainActivity extends AppCompatActivity implements TextureView.SurfaceTextureListener {private int width = 1920;private int height = 1080;// View that contains the Surface Textureprivate TextureView m_surface;// Object that connects to our server and gets H264 framesprivate H264Provider provider;// Media decoderprivate MediaCodec m_codec;// Async task that takes H264 frames and uses the decoder to update the Surface Textureprivate DecodeFramesTask m_frameTask;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);// Find our desired SurfaceTexture to display the streamm_surface = (TextureView) findViewById(R.id.textureView);// Add the SurfaceTextureListenerm_surface.setSurfaceTextureListener(this);}@Override// Invoked when a TextureView's SurfaceTexture is ready for usepublic void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture, int i, int i1) {// when the surface is ready, we make a H264 provider Object.  When its constructor// runs it starts an AsyncTask to log into our server and start getting frames// I have dummed down this demonstration to access the local h264 video from the raw resources dirprovider = new H264Provider(getResources().openRawResource(R.raw.video));//Create the format settings for the MediaCodecMediaFormat format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height);// Set the SPS frameformat.setByteBuffer("csd-0", ByteBuffer.wrap(provider.getSPS()));// Set the PPS frameformat.setByteBuffer("csd-1", ByteBuffer.wrap(provider.getPPS()));// Set the buffer sizeformat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, width * height);try {// Get an instance of MediaCodec and give it its Mime typem_codec = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);// Configure the codecm_codec.configure(format, new Surface(m_surface.getSurfaceTexture()), null, 0);// Start the codecm_codec.start();// Create the AsyncTask to get the frames and decode them using the Codecm_frameTask = new DecodeFramesTask();m_frameTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);}catch (Exception e){e.printStackTrace();}}@Override// Invoked when the SurfaceTexture's buffer size changedpublic void onSurfaceTextureSizeChanged(SurfaceTexture surfaceTexture, int i, int i1) {}@Override// Invoked when the specified SurfaceTexture is about to be destroyedpublic boolean onSurfaceTextureDestroyed(SurfaceTexture surfaceTexture) {return false;}@Override// Invoked when the specified SurfaceTexture is updated through updateTextImage()public void onSurfaceTextureUpdated(SurfaceTexture surfaceTexture) {}private class DecodeFramesTask extends  AsyncTask<String, String, String> {@Overrideprotected String doInBackground(String... strings) {while (!isCancelled()) {// Get the next framebyte[] frame = provider.nextFrame();// Now we need to give it to the Codec to decode into the surface// Get the input buffer from the decoder// Pass in -1 here as in this example we don't have a playback time referenceint inputIndex = m_codec.dequeueInputBuffer(-1);// If the buffer number is valid use the buffer with that indexif (inputIndex >= 0) {ByteBuffer buffer = m_codec.getInputBuffer(inputIndex);try {buffer.put(frame);}catch(NullPointerException e) {e.printStackTrace();}// Tell the decoder to process the framem_codec.queueInputBuffer(inputIndex, 0, frame.length, 0, 0);}MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();int outputIndex = m_codec.dequeueOutputBuffer(info, 0);if (outputIndex >= 0) {// Output the image to our SufaceTexturem_codec.releaseOutputBuffer(outputIndex, true);}// wait for the next frame to be ready, our server makes a frame every 250mstry {Thread.sleep(250);}catch (Exception e) {e.printStackTrace();}}return "";}@Overrideprotected void onPostExecute(String result) {try {m_codec.stop();m_codec.release();}catch (Exception e) {e.printStackTrace();}provider.release();}}@Overridepublic void onStop() {super.onStop();m_frameTask.cancel(true);provider.release();}
}
package com.marchnetworks.decodeh264;import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;public class H264Provider {private byte[] sps_params = null;private byte[] pps_params = null;private byte[] i_frame = null;public H264Provider(InputStream inStream) {try {int firstStartCodeIndex = 6;int secondStartCodeIndex = 0;int thirdStartCodeIndex = 0;//byte[] data = new byte[inStream.available()];byte[] data = toByteArray(inStream);for (int i = 0; i < 100; i++){if (data[i] == 0x00 && data[i+1] == 0x00 && data[i+2] == 0x00 && data[i+3] == 0x01){if ((data[i + 4] & 0x1F) == 7) //SPS{firstStartCodeIndex = i;break;}}}int firstNaluSize = 0;for (int i = firstStartCodeIndex + 4; i < firstStartCodeIndex + 100; i++){if (data[i] == 0x00 && data[i+1] == 0x00 && data[i+2] == 0x00 && data[i+3] == 0x01){if (firstNaluSize == 0){firstNaluSize = i - firstStartCodeIndex;}if ((data[i + 4] & 0x1F) == 8) //PPS{secondStartCodeIndex = i;break;}}}int secondNaluSize = 0;for (int i = secondStartCodeIndex + 4; i < secondStartCodeIndex + 130; i++){if (data[i] == 0x00 && data[i+1] == 0x00 && data[i+2] == 0x00 && data[i+3] == 0x01){if (secondNaluSize == 0){secondNaluSize = i - secondStartCodeIndex;}if ((data[i+4] & 0x1F) == 5) //IFrame{thirdStartCodeIndex = i;break;}}}int thirdNaluSize = 0;int counter = thirdStartCodeIndex + 4;while (counter++ < data.length - 1){if (data[counter] == 0x00 && data[counter + 1] == 0x00 && data[counter + 2] == 0x00 && data[counter + 3] == 0x01){thirdNaluSize = counter - thirdStartCodeIndex;break;}}// This is how you would remove the "\x00\x00\x00\x01"//  byte[] firstNalu = new byte[firstNaluSize - 4];//  byte[] secondNalu = new byte[secondNaluSize - 4];//  byte[] thirdNalu = new byte[thirdNaluSize];//  System.arraycopy(data, thirdStartCodeIndex, thirdNalu, 0, thirdNaluSize);//  System.arraycopy(data, firstStartCodeIndex+4, firstNalu, 0, firstNaluSize-4);//  System.arraycopy(data, secondStartCodeIndex+4, secondNalu, 0, secondNaluSize-4);byte[] firstNalu = new byte[firstNaluSize];byte[] secondNalu = new byte[secondNaluSize];byte[] thirdNalu = new byte[thirdNaluSize];System.arraycopy(data, thirdStartCodeIndex, thirdNalu, 0, thirdNaluSize);System.arraycopy(data, firstStartCodeIndex, firstNalu, 0, firstNaluSize);System.arraycopy(data, secondStartCodeIndex, secondNalu, 0, secondNaluSize);sps_params = firstNalu;pps_params = secondNalu;i_frame = thirdNalu;}catch (IOException e) {e.printStackTrace();}}public byte[] getSPS () {return sps_params;}public byte[] getPPS () {return pps_params;}public byte[] nextFrame () {return i_frame;}public void release () {// Logout of server;}/*** Simple function to return a byte array from an input stream*/private byte[] toByteArray(InputStream in) throws IOException {ByteArrayOutputStream out = new ByteArrayOutputStream();int read = 0;byte[] buffer = new byte[1024];while (read != -1) {read = in.read(buffer);if (read != -1)out.write(buffer,0,read);}out.close();return out.toByteArray();}
}

核心的代码

 private void initDecoder() throws IOException {/*** 工作流是这样的: 以编码为例,首先要初始化硬件编码器,配置要编码的格式、视频文件的长宽、码率、帧率、关键帧间隔等等。* 这一步叫configure。之后开启编码器,当前编码器便是可用状态,随时准备接收数据。* 下一个过程便是编码的running过程,在此过程中,需要维护两个buffer队列,InputBuffer 和OutputBuffer,* 用户需要不断出队InputBuffer (即dequeueInputBuffer),往里边放入需要编码的图像数据之后再入队等待处理,* 然后硬件编码器开始异步处理,一旦处理结束,他会将数据放在OutputBuffer中,并且通知用户当前有输出数据可用了,* 那么用户就可以出队一个OutputBuffer,将其中的数据拿走,然后释放掉这个buffer。* 结束条件在于end-of-stream这个flag标志位的设定。在编码结束后,编码器调用stop函数停止编码,* 之后调用release函数将编码器完全释放掉,整体流程结束。* */mCodec = MediaCodec.createDecoderByType(MIME_TYPE);MediaFormat mediaFormat = MediaFormat.createVideoFormat(MIME_TYPE, VIDEO_WIDTH, VIDEO_HEIGHT);//MediaFormat为媒体格式
//        BITRATE_MODE_CQ: 表示完全不控制码率,尽最大可能保证图像质量
//        BITRATE_MODE_CBR: 表示编码器会尽量把输出码率控制为设定值
//        BITRATE_MODE_VBR: 表示编码器会根据图像内容的复杂度(实际上是帧间变化量的大小)
//        mediaFormat.setInteger(MediaFormat.KEY_BITRATE_MODE,MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR);mediaFormat.setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CQ);
//        mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, 9000000);mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 16);mediaFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, VIDEO_WIDTH * VIDEO_HEIGHT);mediaFormat.setInteger(MediaFormat.KEY_MAX_HEIGHT, VIDEO_HEIGHT);mediaFormat.setInteger(MediaFormat.KEY_MAX_WIDTH, VIDEO_WIDTH);//设置为 0,表示希望每一帧都是 KeyFrame。
//        mediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 0);mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);mCodec.configure(mediaFormat, mSurfaceView.getHolder().getSurface(), null, 0);mCodec.start();//当创建编解码器的时候处于未初始化状态。

这里我没有使用固定码率模式,即在下位机就没有使用固定码率,而是固定了质量,当图像变化剧烈时,会自动加大码率,但保持质量恒定,这是Q群里一位大佬的建议。

解码部分

    public boolean onFrame(byte[] buf, int offset, int length) {int inputBufferIndex = mCodec.dequeueInputBuffer(TIMEOUT_US);//dequeueInputBuffer:从输入流队列中取数据进行编码操作Log.e("Media", "onFrame index:" + inputBufferIndex);if (inputBufferIndex >= 0) {ByteBuffer inputBuffer = mCodec.getInputBuffer(inputBufferIndex);//获取需要编码数据的输入流队列,inputBuffer.put(buf, offset, length);//从buf数组中的offset到offset+length区域读取数据并使用相对写写入此byteBufferinputBuffer.clear();//初始化inputBuffer.limit(buf.length);//此参数表示帧的录制时间,因此需要增加您要编码的帧与上一个帧之间的距离。mCodec.queueInputBuffer(inputBufferIndex, 0, length, mCount* TIME_INTERNAL, 0);mCount++;} else {return false;}//getInputBuffers:获取需要编码数据的输入流队列,返回的是一个ByteBuffer数组//queueInputBuffer:输入流入队列//dequeueInputBuffer:从输入流队列中取数据进行编码操作//getOutputBuffers:获取编解码之后的数据输出流队列,返回的是一个ByteBuffer数组//dequeueOutputBuffer:从输出队列中取出编码操作之后的数据//releaseOutputBuffer:处理完成,释放ByteBuffer数据MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();int outputBufferIndex = mCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US);while (outputBufferIndex >= 0) {mCodec.releaseOutputBuffer(outputBufferIndex, true);outputBufferIndex = mCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US);}return true;}

这里只需要把下位机编码好的0001或001开头的buf传进来就实现解码的全部流程了,但是由于API的不熟悉我还是掉了个坑,解码后发现花屏,其现象是当画面静止的时候一切正常,但是一旦画面有动作就会局部花屏,有的时候也很严重,起初以为是网络不稳定或是TCP传输的时候丢帧了,但是对比数据长度并没有发现异常,也有群友说可能是数据错位导致的,甚至有群友建议我用MD5去校验数据,调试这个BUG确实挺伤脑筋,直到看到了这个:
End-of-stream Handling
When you reach the end of the input data, you must signal it to the codec by specifying the BUFFER_FLAG_END_OF_STREAM flag in the call to queueInputBuffer. You can do this on the last valid input buffer, or by submitting an additional empty input buffer with the end-of-stream flag set. If using an empty buffer, the timestamp will be ignored.

The codec will continue to return output buffers until it eventually signals the end of the output stream by specifying the same end-of-stream flag in the MediaCodec.BufferInfo set in dequeueOutputBuffer or returned via onOutputBufferAvailable. This can be set on the last valid output buffer, or on an empty buffer after the last valid output buffer. The timestamp of such empty buffer should be ignored.

Do not submit additional input buffers after signaling the end of the input stream, unless the codec has been flushed, or stopped and restarted.
然后修改:

mCodec.queueInputBuffer(inputBufferIndex, 0, length, mCount* TIME_INTERNAL, MediaCodec.BUFFER_FLAG_KEY_FRAME);

重新立了个flag 但是问题依旧。
解决问题还得从根上起,我保存了下位机编码的码流和android上的码流直接用播放器播放,发现并不是解码器的问题,而是丢帧的问题。最终错误定位是由于tcp粘包造成的错位和丢帧,调整了一下策略解决了问题。

  • 补充
    做调试的时候要注意网络速率的变化,在网速不好的情况下无论你如何优化参数都会丢帧,这时候要主动校验然后去手动丢帧,也可以降低上位机的质量,防止网络阻塞,如果抖动严重可以考虑增加编码端流的I帧插入速率

目前对这个结果很满意,其实我之前也尝试过使用ffmpeg来图传,但是效果始终不太理想

self.command = ['ffmpeg',# '-f','alsa',#'-ar','11025',#'-i','hw:0',#'-vol','200',# '-y','-analyzeduration','1000000','-f', 'rawvideo','-fflags','nobuffer','-vcodec','rawvideo','-pix_fmt','bgr24','-s', "{}x{}".format(self.width, self.height),'-r', self.fps,'-i', '-','-c:v', 'h264_omx','-pix_fmt', 'yuv420p','-preset', 'ultrafast','-tune:v', 'zerolatency','-f', 'flv',self.rtmpUrl]print(self.command)self.p = sp.Popen(self.command, stdin=sp.PIPE)

不知道是我打开的方式不对还是研究的不透彻,效果始终不好。算了我也用不上这种方式了。

树莓派(网络摄像头)4G网络720p高清图传(python3.7+SpringBoot-JavaNetty+Android-Mediacodec)相关推荐

  1. python网络爬虫快速下载4K高清壁纸

    python网络爬虫快速下载4K高清壁纸 此处给出下载壁纸的链接地址彼岸图网,进入网站之后,我们看到可以下载风景,游戏,动漫,美女等类型的4K图片,装逼一下,re库有贪婪匹配,那我们就写一个通用代码来 ...

  2. 《​国家地理杂志》720P 高清全集 468GB 英语中字 BT下载

    简介: <国家地理杂志>(National Geographic Magazine,或简称为<国家地理>)是美国国家地理学会的官方杂志,在国家地理学会1888年成立后的9个月开 ...

  3. micropython定制_树莓派开发实战 第2版 高清pdf

    链接:https://pan.baidu.com/s/1oD6ZiYrTUf2lq-Pz-zBBFQ 提取码:4kzq 树莓派(Raspberry Pi)是一款基于Linux系统的.只有一张信用卡大小 ...

  4. linux网络摄像头服务器,网络摄像头Logitech和Linux

    我有罗技c310相机,宣称的特点是720p30fps. 如果您将相机连接到Windows,则记录与所述720p 30fps完全一致-图片清晰. 挑战是将同一个摄像头连接到Orangepi(服务器Arm ...

  5. ping 丢包 网络摄像头_网络摄像机频繁掉线的处理方法

    在实际工作中,摄像机装好后,用的好好的,忽然掉线了,过了一会儿自己又上线了,然后趁你不注意又莫名其妙的掉线了.如何解决网络摄像机或者说网络设备频繁掉线的问题?从下面五个方面首手处理这个问题: 第一.检 ...

  6. linux查看网络摄像头,用网络查看usb摄像头的图像

    最近要把那个usb摄像头简单做成一个网络摄像头 板子里的服务端暂时用的c,客户端用的qt 现在的传输方式是: 两个线程,一个负责采集图像数据,然后转换格式压缩成jpeg文件,一个320*240的图片平 ...

  7. 谷歌牛逼:720p高清+长镜头,网友:对短视频行业冲击太大

    来源:量子位 内容生成AI进入视频时代! Meta发布「用嘴做视频」仅一周,谷歌CEO劈柴哥接连派出两名选手上场竞争. 第一位Imagen Video与Meta的Make-A-Video相比突出一个高 ...

  8. 谷歌最新发布两大视频生成工作:720p高清+长镜头,网友:对短视频行业冲击太大......

    点击下方卡片,关注"CVer"公众号 AI/CV重磅干货,第一时间送达 点击进入-> CV 微信技术交流群 梦晨 Pine 发自 凹非寺 转载自:量子位(QbitAI) 内容 ...

  9. 减少USB 1.1 2.0 端口驱动程序延时_树莓派 USB摄像头 实现网络监控( MJPG-Streamer)...

    MJPG简介: MJPG是MJPEG的缩写,但是MJPEG还可以表示文件格式扩展名. MJPEG 全名为 "Motion Joint Photographic Experts Group&q ...

最新文章

  1. No 'Access-Control-Allow-Origin' header is present on the requested resource.
  2. 线下生意再次“受宠”:大数据给你添点料
  3. 华为旗舰陆续升级鸿蒙系统,华为鸿蒙重磅来袭:今年4月起 华为旗舰手机将陆续升级鸿蒙系统!...
  4. 输入对5层网络迭代次数的影响
  5. ASP.NET MVC 自定义模型绑定1 - 自动把以英文逗号分隔的 ID 字符串绑定成 Listint...
  6. Java Language Changes for Java SE 9
  7. mybatis学习笔记-01什么是mybatis
  8. 基于TableStore的数据采集分析系统介绍 1
  9. 如何得到发送邮件服务器地址(SMTP地址)
  10. 在Windows下使用CMake+MinGW搭建C/C++编译环境
  11. [Linux]磁盘端口I/O
  12. 全球CORS网 部分站点数据下载链接
  13. 如果写不出好的和弦就在洒满阳光的钢琴前一起吃布丁+与8有关的事儿
  14. 医院九阵系统服务器电源,九阵医院信息管理系统
  15. 基于Java的Minecraft游戏后端自定义插件 的Java实践项目整理
  16. 机器学习-40-GAN-07-Feature Extraction(InfoGAN,VAE-GAN,BiGAN,Feature Disentangle(Voice Conversion))
  17. 复杂度分析--时间复杂度
  18. 科大奥锐干涉法测微小量实验的数据_大学物理实验报告答案解析大全(实验数据).doc...
  19. 服务器操作系统怎么做映像,如何网络捕获使用 Sysprep 和 PXE 配置的服务器操作系统映像...
  20. Processing编程学习指南2.4 速写本

热门文章

  1. 高超声速飞行器的反步法控制
  2. windows终止进程 /万能恢复大师、WRSvn、WRtlname删除
  3. eclipse安装完adt插件后只有一个小图标 还打不开怎么回事
  4. 抖音养号教程技巧,做抖音怎么养号上热门!
  5. 选课系统java源文件_学生选课系统 - WEB源码|JSP源码/Java|源代码 - 源码中国
  6. LED显示屏控制系统软件的开发
  7. 宽窄带信号的区别(作者:doctorstar)
  8. 以下不是html5新特性的是,Html5的一些新特性
  9. oppo a115k java,oppoA115k
  10. 八十年代的计算机游戏,盘点PC游戏史上最重要的50款游戏!第1期:60-80年代