/   今日科技快讯   /

近日微软中国在其官方微博宣布,未来一年内将继续扩大在华招聘,员工总数预计突破1万人。在扩大招聘的基础上,微软还计划在未来三到五年内,对位于北京、上海及苏州的园区进行升级扩建,这三地园区的建设和运营将迎合当下混合式未来办公的灵活需求。据悉,微软中国现阶段正在实行混合工作制,员工超过50%的工作时间可居家办公。

/   作者简介   /

明天就是周六啦,祝大家周末愉快!

本篇文章来自史大拿的投稿,文章主要分享了手势解锁的实现,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。

史大拿的博客地址:

https://blog.csdn.net/weixin_44819566?type=blog

/   前言   /

废话不多说,先来看今天要完成的效果:

Tips:不止3X3 或者 5X5 ,如果你想,甚至可以设置10*10

/   画圆   /

先以3*3的九宫格来介绍!

我们要画成这样的效果, 画的是有一点丑,但是没关系.

首先来分析一下怎么花,这9个点的位置如何确定:

  • 我们为了平均分, 单个圆的外层矩形 宽 = view.width / 3

  • 高 = 宽

  • 1号圆的圆心位置 = 0个矩形的宽度 = view.width / (3 * 2) + ( view.width / 3 ) * 0

  • 2号圆的圆心位置 = 1号圆的圆心位置 + 1个矩形的宽度 = view.width / (3 * 2) + (view.width / 3) * 1

  • 3号圆的圆心位置 = 1号圆的圆心位置 + 2个矩形的宽度 = view.width / (3 * 2) + (view.width / 3) * 2

高坐标的计算也是如此

来看看目前的代码:

class BlogUnLockView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {//strokeJoin = Paint.Join.BEVEL}// 大圆半径private val bigRadius by lazy { width / (NUMBER * 2) * 0.7f }// 小圆半径private val smallRadius by lazy { bigRadius * 0.2f }companion object {const val NUMBER = 3}private val unLockPoints = arrayListOf<ArrayList<UnLockBean>>()override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {super.onSizeChanged(w, h, oldw, oldh)// 矩形直径val diameter = width / NUMBER// val ratio = (NUMBER * 2f)var index = 1// 循环每一行行for (i in 0 until NUMBER) {val list = arrayListOf<UnLockBean>()// 循环每一列for (j in 0 until NUMBER) {list.add(UnLockBean(width / ratio + diameter * j,height / ratio + diameter * i,index++))}unLockPoints.add(list)}}override fun onDraw(canvas: Canvas) {canvas.drawColor(Color.YELLOW)unLockPoints.forEach {it.forEach { data ->// 绘制大圆paint.alpha = (255 * 0.6).toInt()canvas.drawCircle(data.x, data.y, bigRadius, paint)// 绘制小圆paint.alpha = 255canvas.drawCircle(data.x, data.y, smallRadius, paint)}}}
}

目前问题:整个view占满了屏幕,需要测量

测量代码比较简单,就是让宽和高一样即可

此时改变number变量,就可以设置几行几列,例如这样:

接下来我们就处理手势事件,按下滑动,抬起等,来改变选中

/   onTouchEvent   /

在事件处理之前先来分析一下需要几种事件,对于解锁功能来说:

  • ORIGIN 刚开始,还没有触摸

  • DOWN 正在触摸中(输入密码)

  • UP 触摸结束 (输入密码正确)

  • ERROR 触摸结束 (输入密码错误)

那么就先定义4种颜色,来表示这4种状态:

companion object {// 原始颜色private var ORIGIN_COLOR = Color.parseColor("#D8D9D8")// 按下颜色private var DOWN_COLOR = Color.parseColor("#3AD94E")// 抬起颜色private var UP_COLOR = Color.parseColor("#57D900")// 错误颜色private var ERROR_COLOR = Color.parseColor("#D9251E")
}

接下来挨个处理事件

DOWN(按下)

首先需要思考,在按下的时候要做什么事情:

判断是否选中

/*
* TODO 判断是否选中某个圆
* @param x,y: 点击坐标位置
*/
private fun isContains(x: Float, y: Float) = let {unLockPoints.forEach {it.forEach { data ->// 循环所有坐标 判断两个位置是否相同if (PointF(x, y).contains(PointF(data.x, data.y), bigRadius)) {return@let data}}}return@let null
}// 判断一个点是否在另一个点范围内
fun PointF.contains(b: PointF, bPadding: Float = 0f): Boolean {val isX = this.x <= b.x + bPadding && this.x >= b.x - bPaddingval isY = this.y <= b.y + bPadding && this.y >= b.y - bPaddingreturn isX && isY
}

思路: 通过比较 按下位置和所有位置,判断是否有相同的

  • 如果有相同的,那么就返回对应坐标

  • 如果没有相同的,那么就返回null

@SuppressLint("ClickableViewAccessibility")override fun onTouchEvent(event: MotionEvent): Boolean {when (event.action) {MotionEvent.ACTION_DOWN -> {// 判断是否选中val pointF = isContains(event.x, event.y)pointF?.let {// 将当前类型变为按下类型it.type = JiuGonGeUnLockView.Type.DOWN}}...}invalidate()return true}override fun onDraw(canvas: Canvas) {
//        canvas.drawColor(Color.YELLOW)unLockPoints.forEach {it.forEach { data ->// 根据类型设置颜色paint.color = getTypeColor(data.type)// 绘制大圆paint.alpha = (255 * 0.6).toInt()canvas.drawCircle(data.x, data.y, bigRadius, paint)// 绘制小圆paint.alpha = 255canvas.drawCircle(data.x, data.y, smallRadius, paint)}}
}/// TODO 获取类型对应颜色
private fun getTypeColor(type: JiuGonGeUnLockView.Type): Int {return when (type) {JiuGonGeUnLockView.Type.ORIGIN -> ORIGIN_COLORJiuGonGeUnLockView.Type.DOWN -> DOWN_COLORJiuGonGeUnLockView.Type.UP -> UP_COLORJiuGonGeUnLockView.Type.ERROR -> ERROR_COLOR}
}

MOVE(移动)

move事件和down事件的逻辑是一样的,滑动的过程中判断点是否选中,然后绘制点

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {when (event.action) {MotionEvent.ACTION_DOWN -> {val pointF = isContains(event.x, event.y)pointF?.let {// 将当前类型改变为按下类型it.type = JiuGonGeUnLockView.Type.DOWN}}MotionEvent.ACTION_MOVE -> {val pointF = isContains(event.x, event.y)pointF?.let {// 将当前类型改变为按下类型it.type = JiuGonGeUnLockView.Type.DOWN}}....}invalidate()return true
}

可以看出,效果是基本完成了,但是还有一个小错误

通常我们在九宫格的时候,一般都是先按下一个点才能滑动, 否则是不能滑动的,

现在的问题是,直接就可以滑动,所以还需要调整一下

那么我们就需要在down事件中标记一下是否按下,然后在move事件中判断一下

// 是否按下
private var isDOWN = false@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {when (event.action) {MotionEvent.ACTION_DOWN -> {val pointF = isContains(event.x, event.y)pointF?.let {// 将当前类型改变为按下类型it.type = JiuGonGeUnLockView.Type.DOWNisDOWN = true // 表示按下}}MotionEvent.ACTION_MOVE -> {if (!isDOWN) {return super.onTouchEvent(event)}val pointF = isContains(event.x, event.y)pointF?.let {// 将当前类型改变为按下类型it.type = JiuGonGeUnLockView.Type.DOWN}}MotionEvent.ACTION_CANCEL,MotionEvent.ACTION_UP -> {isDOWN = false // 标记没有按下}}invalidate()return true
}

UP(抬起)

思路分析:

抬起的时候要做很多事情

  • 判断输入密码是否正确

  • 密码输入正确,那么就改变为深绿色

  • 密码输入错误,就改变为红色

  • 完成之后,还需要吧所有的状态清空

在这里的时候,先不判断密码是否成功, 默认都是成功的,

  • 先吧输入的密码toast出来

  • 并且吧状态清空

等结尾的时候再来判断密码.

那么此时肯定是需要将所有选中的都记录下来, 然后在up事件中操作即可

// 记录选中的坐标
private val recordList = arrayListOf<UnLockBean>()@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {when (event.action) {MotionEvent.ACTION_DOWN -> {val pointF = isContains(event.x, event.y)pointF?.let {// 将当前类型改变为按下类型it.type = JiuGonGeUnLockView.Type.DOWNisDOWN = truerecordList.add(it)}}MotionEvent.ACTION_MOVE -> {if (!isDOWN) {return super.onTouchEvent(event)}val pointF = isContains(event.x, event.y)pointF?.let {// 将当前类型改变为按下类型it.type = JiuGonGeUnLockView.Type.DOWN// 这里会重复调用,所以需要判断是否包含,如果不包含才添加if (!recordList.contains(it)) {recordList.add(it)}}}MotionEvent.ACTION_CANCEL,MotionEvent.ACTION_UP -> {// 将结果打印recordList.map {it.index}.toList() toast contextclear()}}invalidate()return true
}/// 清空所有状态private fun clear() {recordList.forEach {// 将所有选中状态还原it.type = JiuGonGeUnLockView.Type.ORIGIN}recordList.clear()isDOWN = false // 标记没有按下invalidate()}

/   画线连接   /

假设现在需要连接 1,5,6,9

那么可以通过Path()来画线

在DOWN事件中,通过moveTo()移动到1的位置

在MOVE事件中,通过lineTo()画5,6,9的位置 即可

private val path = Path()@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {when (event.action) {MotionEvent.ACTION_DOWN -> {val pointF = isContains(event.x, event.y)pointF?.let {/// 隐藏部分代码path.moveTo(it.x, it.y)}}MotionEvent.ACTION_MOVE -> {val pointF = isContains(event.x, event.y)pointF?.let {/// 隐藏部分代码// 这里会重复调用,所以需要判断是否包含,如果不包含才添加if (!recordList.contains(it)) {recordList.add(it)path.lineTo(it.x, it.y) // 连接到移动的位置}}}MotionEvent.ACTION_CANCEL,MotionEvent.ACTION_UP -> {// 将结果打印recordList.map {it.index}.toList() toast contextclear()}}invalidate()return true
}/** 作者:史大拿* 创建时间: 9/14/22 1:38 PM* TODO 用来清空标记*/
private fun clear() {path.reset() // 重置recordList.forEach {// 将所有选中状态还原it.type = JiuGonGeUnLockView.Type.ORIGIN}recordList.clear()isDOWN = false // 标记没有按下
}override fun onDraw(canvas: Canvas) {paint.style = Paint.Style.FILLunLockPoints.forEach {/// 隐藏部分代码}paint.style = Paint.Style.STROKEpaint.strokeWidth = 4.dppaint.color = DOWN_COLOR // 默认按下颜色canvas.drawPath(path, paint)
}

可以看出,已经完成了画连接线,但是还缺少一条指示当前手指位置的线,

我叫他移动线, (好土的名字)

移动线就2个坐标

  • 开始位置 (最后一个选中的位置)

  • 结束位置 (当前手指按下的位置)

private val line = Pair(PointF(), PointF())@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {when (event.action) {MotionEvent.ACTION_DOWN -> {val pointF = isContains(event.x, event.y)pointF?.let {/// 隐藏代码line.first.x = it.xline.first.y = it.y}}MotionEvent.ACTION_MOVE -> {val pointF = isContains(event.x, event.y)pointF?.let {if (!recordList.contains(it)) {隐藏代码// 最后一个选中的位置line.first.x = it.xline.first.y = it.y}}// 手指的位置line.second.x = event.xline.second.y = event.y}....}invalidate()return true
}override fun onDraw(canvas: Canvas) {paint.style = Paint.Style.FILLunLockPoints.forEach {/// 隐藏代码}// 绘制连接线paint.style = Paint.Style.STROKEpaint.strokeWidth = 4.dppaint.color = DOWN_COLOR // 默认按下颜色canvas.drawPath(path, paint)// 绘制移动线if (line.first.x != 0f && line.second.x != 0f) {canvas.drawLine(line.first.x,line.first.y,line.second.x,line.second.y,paint)}}

此时效果就差不多了,画笔默认是实心圆, 来看看空心效果

/   空心效果   /

空心效果很简单,只需要调整画笔的style即可

override fun onDraw(canvas: Canvas) {// 实心效果
//        paint.style = Paint.Style.FILL// 空心效果paint.style = Paint.Style.STROKEpaint.strokeWidth = 4.dp// canvas.drawXXX()}

可以看出,此时的效果和我们想的一样,但是画线的时候从小圆圆心穿过了,不太好看

有没有一种办法,让线不从圆心穿过

那么就先来分析一下:

假设现在是从7移动到2

那么就需要连接C点和F点,只需要计算出C点和F点的坐标即可

先来分析现在的已知条件:

  • dx = end.x - start.x

  • dy = end.y - start.y

  • d = (dx平方 + dy平方) 开根号

  • 小圆半径 = smallRadius

那么就可以算出当前的偏移量:

  • offsetX = dx * (smallRadius / d)

  • offsetY = dy * (smallRadius / d)

知道偏移量,就可以算出C和F的坐标:

那么C的坐标为:

  • C.x = start.x + offsetX

  • C.y = start.y + offsetY

那么F的坐标为:

  • F.x = end.x + offsetX

  • F.y = end.y + offsetY

只要C和F的坐标之后

只需要通过path.moveTo() 移动到C的位置

通过path.lineTo() 移动到F的位置即可

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {when (event.action) {MotionEvent.ACTION_DOWN -> {/// ... }MotionEvent.ACTION_MOVE -> {val pointF = isContains(event.x, event.y)pointF?.let {// 将当前类型改变为按下类型it.type = JiuGonGeUnLockView.Type.DOWN// 这里会重复调用,所以需要判断是否包含,如果不包含才添加if (!recordList.contains(it)) {recordList.add(it)if (recordList.size >= 2) {// TODO 不穿过圆心val start = recordList[recordList.size - 2]val end = recordList[recordList.size - 1]val d = PointF(start.x, start.y).distance(PointF(end.x, end.y))val dx = (end.x - start.x)val dy = (end.y - start.y)val offsetX = dx * smallRadius / dval offsetY = dy * smallRadius / dval cX = start.x + offsetXval cY = start.y + offsetYpath.moveTo(cX, cY)val fX = end.x - offsetXval fY = end.y - offsetYpath.lineTo(fX, fY)// lineline.first.x = it.x + offsetXline.first.y = it.y + offsetY}}}// 手指的位置line.second.x = event.xline.second.y = event.y}/// 隐藏UP代码}invalidate()return true
}// 计算两点之间的距离
fun PointF.distance(b: PointF): Float = let {val a = this// 这里 * 1.0 是为了转Doubleval dx = b.x - a.x * 1.0val dy = b.y - a.y * 1.0return@let sqrt(dx.pow(2) + dy.pow(2)).toFloat()
}

所有的效果基本就差不多了,接下来来比较密码

/   比较密码   /

思路分析:

先将正确密码集合传过来,然后和输入的密码做比较

首先先判断两个集合的长度如果长度不一样,那么密码肯定是不同的,直接标记为错误即可

如果长度一样,只需要比较每一个值是否相同相同则输入成功,将正确结果回调回去

有一个不相同,则输入失败,标记为错误即可

// 密码
open var password = listOf<Int>()MotionEvent.ACTION_UP -> {// 清空移动线line.first.x = 0fline.first.y = 0fline.second.x = 0fline.second.y = 0f// 标记是否成功val isSuccess =// 先比较长度是否相同if (recordList.size == password.size) {val list = recordList.zip(password).filter {// 通过判断每一个值it.first.index == it.second}.toList()// 如果每一个值都相同,那么就成功list.size == password.size} else {false}// 密码错误,将标记改变成成错误if (!isSuccess) {recordList.forEach {it.type = JiuGonGeUnLockView.Type.ERROR}"输入失败" toast context} else {"输入成功" toast context}// 延迟1秒清空postDelayed({clear()}, 1000)
}

现在已经可以完成输入密码了,

但是状态还不对,我们希望连接线的颜色和圆的颜色一致,

当然我们可以这样:

override fun onDraw(canvas: Canvas)
//        paint.style = Paint.Style.FILLpaint.style = Paint.Style.STROKEpaint.strokeWidth = 4.dpunLockPoints.forEach {it.forEach { data ->// 根据类型设置颜色paint.color = getTypeColor(data.type)// 绘制大圆paint.alpha = (255 * 0.6).toInt()canvas.drawCircle(data.x, data.y, bigRadius, paint)// 绘制小圆paint.alpha = 255canvas.drawCircle(data.x, data.y, smallRadius, paint)// 绘制连接线canvas.drawPath(path, paint)// 绘制移动线if (line.first.x != 0f && line.second.x != 0f) {canvas.drawLine(line.first.x,line.first.y,line.second.x,line.second.y,paint)}}}}

但是我还是选择了通过一个全局变量,来记录当前的状态,然后给连接线和移动线设置颜色

代码很简单,就不展示了,直接看效果:

到此时,效果就基本完成了,

但是,写完发现,代码真的太乱了,而且有很多设置的东西,

比如说:

  • 默认颜色

  • 移动颜色

  • 输入成功颜色

  • 输入失败颜色

  • 解锁的大小

  • 例如3,就是3 X 3 5就是5 X 5

  • 样式

  • 空心 or 实心

一般遇到这种情况我认为有2种方式

  • 自定义属性

  • 设计模式

自定义属性用的很多,这里我就通过Adapter模式来优化一下

先来定义规范

abstract class UnLockBaseAdapter {// 设置宫格个数// 例如输入3: 表示3*3abstract fun getNumber(): Int// 设置样式abstract fun getStyle(): JiuGonGeUnLockView.Style/** 作者:史大拿* 创建时间: 9/14/22 10:24 AM* TODO 画连接线时,是否穿过圆心*/open fun lineCenterCircle() = false// 设置原始颜色open fun getOriginColor(): Int = let {return Color.parseColor("#D8D9D8")}// 设置按下颜色open fun getDownColor(): Int = let {return Color.parseColor("#3AD94E")}// 设置抬起颜色open fun getUpColor(): Int = let {return Color.parseColor("#57D900")}// 设置错误颜色open fun getErrorColor(): Int = let {return Color.parseColor("#D9251E")}
}

实现:

class UnLockAdapter : UnLockBaseAdapter() {override fun getNumber(): Int = 5override fun getStyle(): JiuGonGeUnLockView.Style = JiuGonGeUnLockView.Style.STROKEoverride fun getOriginColor(): Int {return Color.YELLOW}
}

读取数据:

open var adapter: UnLockBaseAdapter? = nulloverride fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {super.onMeasure(widthMeasureSpec, heightMeasureSpec)if (adapter == null) {throw AndroidRuntimeException("请设置Adapter")}adapter?.also {NUMBER = it.getNumber()ORIGIN_COLOR = it.getOriginColor()DOWN_COLOR = it.getDownColor()UP_COLOR = it.getUpColor()ERROR_COLOR = it.getErrorColor()}
}

来看看最终效果:

思路参考 : https://www.jianshu.com/p/74e760ef8d10

完整代码:https://gitee.com/lanyangyangzzz/custom-view-project

推荐阅读:

我的新书,《第一行代码 第3版》已出版!

AOP思想与插件化技术在安卓上的实践应用

谈一谈在两个商业项目中使用MVI架构后的感悟

欢迎关注我的公众号

学习技术或投稿

长按上图,识别图中二维码即可关注

Android自定义View,九宫格解锁相关推荐

  1. android自定义View: 九宫格解锁

    本系列自定义View全部采用kt 系统:mac android studio: 4.1.3 kotlin version1.5.0 gradle: gradle-6.5-bin.zip 废话不多说,先 ...

  2. Android 自定义View 滑动解锁

    自定义view来绘制一个类似滑动解锁的button,注释都在,效果如下 代码在下面,替换一下资源,可以直接使用,如果需要画的是圆角的话,需要把下面两行注释的DrawLine的注释打开,然后把两行dra ...

  3. android 自定义view: 蛛网/雷达图(三)

    本系列自定义View全部采用kt 系统mac android studio: 4.1.3 kotlin version1.5.0 gradle: gradle-6.5-bin.zip 本篇效果: 蛛网 ...

  4. android自定义view之九宫格解锁

    android自定义view之九宫格解锁 更多细节请看源码 https://github.com/que123567/lockview 1. 定义一个类作为九宫格的格子 包含坐标和索引(用来记录密码) ...

  5. Android技术分享| 【Android 自定义View】多人视频通话控件

    [Android 自定义View]多人视频通话控件 *以上图片截自微信等待中界面 等待中界面 上图是微信多人视频通话时未接通的界面状态,可见每个人的 View 中大致需包含了以下元素. 头像 昵称 L ...

  6. Android自定义View-滑动解锁按钮

    Android自定义View-滑动解锁按钮 写在前面 一.实现的思路 二.先上成品图 三.自定义属性 四.使用 五.具体实现 写在前面 最近由于项目需求,需要有一个类似苹果的滑动解锁控件,抱着万事不求 ...

  7. Android自定义View —— TypedArray

    在上一篇中Android 自定义View Canvas -- Bitmap写到了TypedArray 这个属性 下面也简单的说一下TypedArray的使用 TypedArray 的作用: 用于从该结 ...

  8. Android 自定义View —— Canvas

    上一篇在android 自定义view Paint 里面 说了几种常见的Point 属性 绘制图形的时候下面总有一个canvas ,Canvas 是是画布 上面可以绘制点,线,正方形,圆,等等,需要和 ...

  9. android自定义view获取控件,android 自定义控件View在Activity中使用findByViewId得到结果为null...

    转载:http://blog.csdn.net/xiabing082/article/details/48781489 1.  大家常常自定义view,,然后在xml 中添加该view 组件..如果在 ...

  10. Android自定义View:ViewGroup(三)

    自定义ViewGroup本质是什么? 自定义ViewGroup本质上就干一件事--layout. layout 我们知道ViewGroup是一个组合View,它与普通的基本View(只要不是ViewG ...

最新文章

  1. Dubbo下载-从missing artifactId说起
  2. android安装apk时启动一个服务器,详解Android中App的启动界面Splash的编写方法
  3. 来电通java版_终于有人把Java程序员必学知识点整理出来了,令人有如醍醐灌顶...
  4. [Qt教程] 第47篇 进阶(七) 定制Qt帮助系统
  5. java 类方法应用题,java方法使用
  6. android.support.v7 fragme,打造最强RecyclerView侧滑菜单,长按拖拽Item,滑动删除Item
  7. 使用 DataAdapter 和 DataSet 更新数据库
  8. SAP如何自定义客户编码
  9. ASP.NET验证码的实现
  10. Mac怎么连接多个蓝牙音箱?
  11. 《你的知识需要管理》序:五步打造个人知识力
  12. 我在16ASPX下了一个系统是ACCESS和VS2005做的我想把那个连接数据库的'DB_16aspx'的名字改了进不了了可是?...
  13. 华为交换机作为AC的条件
  14. 如何利用视频做动图?视频转gif动图
  15. 小米路由 php 服务器地址,小米路由器ip地址能改吗 小米路由器ip地址修改-192路由网...
  16. 手机上怎么录制斗鱼直播视频,直播视频怎么录制
  17. 计算机里删除的文件可以在哪里进行恢复,电脑上删除的文件怎么恢复?方法在这里...
  18. 安装ATOM并使用apm
  19. koa 中间件洋葱模型源码分析
  20. 联盟链战国:五大巨头横向对比

热门文章

  1. Canvas绘制曲线
  2. python列表转集合练习
  3. (附源码)计算机毕业设计SSM大学生心理咨询系统 1
  4. 大疆行业无人机接入音视频平台协议详解
  5. DDD实战与进阶学习之值对象
  6. C#中两个常用委托类型
  7. VC MFC C++ MessageBox 确定取消窗口的使用
  8. windows下openvc开发环境
  9. 无障碍人机交互时代已向我们走来,标贝科技推出语音合成评测系统
  10. 我的世界mod开发(3番外)自定义方块/物品模型