android double精度_Android车辆运动轨迹平滑移动(高仿滴滴打车)最佳实践
点击上方“Android技术杂货铺”,选择“标星”
干货文章,第一时间送达!
作者:门心叼龙 链接:https://www.jianshu.com/p/015bd44a56e6
关于车辆运动的相关文章一共写过两篇,一篇为Android车辆运动轨迹大数据采集最佳实践
,另外一篇是Android车辆运动轨迹数据采集服务保活的探索与发现
。
一直打算写一篇车辆运动轨迹平滑移动的文章,年后由于工作项目太忙也就没时间写,工作的事情忙完了,紧接着就是忙自己的另外两个开源项目,一个是Android客户端框架FlyTour
、和另外一个SpringCloud微服务框架FlyCloud
,上个周终于告一段落。
数据也能采集了,而且采集服务保活也做了,有效的避免了数据采集服务在后台被系统杀死的可能,接着就需要把采集到的数据在移动设备上实时的展示出来,这两天我也特意在网上搜索了一下,关于Android车辆运动轨迹平滑移动的文章,还真没有,大部分都是提问的多,问怎么实现的?都是一些只言片语很零碎的一些回答,在实战项目当中没有太大的实用价值。
关于车辆运动,在我们在日常生活中见到最多的就是滴滴打车,想必这款app大家都使用过,当你在app的叫车页面,输入完毕你的目的地,点击叫车,如果有司机接单了,你就清楚的看到车辆会平滑的移动到你所在的位置去接你。今天我就带领大家一步步的实现一个车辆平滑移动的功能。
这个功能是基于高德地图开发的,因此我特意去高德官网查阅了一下,高德的确提供了官方轨迹移动api,我们暂且不用官方API.先用自己的方法去实现一个最简单的功能 我们先来一睹为快:
单次轨迹回放
已知有一段轨迹数据,点击回放按钮,小车沿着路线自动的往前运动,播放完毕也就结束了
public class MoveSingleThread extends Thread{private List mLatLngList;private Marker mCarMarker;public MoveSingleThread(List latLngs, Marker marker) {super(); mLatLngList = latLngs; mCarMarker = marker; }@Overridepublic void run() {super.run(); }public void moveTrack(){// 第一个for循环用来计算走了多少部int step = 0;for (int i = 0; i < mLatLngList.size() - 1; i++) { LatLng startPoint = mLatLngList.get(i); LatLng endPoint = mLatLngList.get(i + 1);double slope = getSlope(startPoint, endPoint);// 是不是正向的标示(向上设为正向)boolean isReverse = (startPoint.latitude > endPoint.latitude);double xMoveDistance = isReverse ? getXMoveDistance(slope) : -1 * getXMoveDistance(slope);// 应该对经纬度同时处理for (double j = startPoint.latitude; !((j >= endPoint.latitude) ^ isReverse); j = j - xMoveDistance) { step++; } }// 通过距离,计算轨迹动画时间间隔double mTimeInterval = 0;// 轨迹回放时间戳if (!TextUtils.isEmpty(mDistance)) {float totalDistance = Float.parseFloat(mDistance) * 1000;if (totalDistance <= 500) { mTimeInterval = 1000.0 / step; } else if (totalDistance > 500 && totalDistance <= 7500) { mTimeInterval = 2.0 * totalDistance / step; } else { mTimeInterval = 15000.0 / step; } }// while (true) {for (int i = 0; i < mLatLngList.size() - 1; i++) {if (stopFlag) { stopFlag = false;break; } mIsCarMoveing = true; LatLng startPoint = mLatLngList.get(i); LatLng endPoint = mLatLngList.get(i + 1); mCarMarker.setPosition(startPoint); mCarMarker.setRotateAngle((float) getAngle(startPoint, endPoint));double slope = getSlope(startPoint, endPoint);// 是不是正向的标示(向上设为正向)boolean isReverse = (startPoint.latitude > endPoint.latitude);double intercept = getInterception(slope, startPoint);double xMoveDistance = isReverse ? getXMoveDistance(slope) : -1 * getXMoveDistance(slope);// 应该对经纬度同时处理double mSleep = 0;for (double j = startPoint.latitude; !((j >= endPoint.latitude) ^ isReverse); j = j - xMoveDistance) { LatLng latLng = null;if (slope != Double.MAX_VALUE) { latLng = new LatLng(j, (j - intercept) / slope);// latLng = new LatLng(j, k); } else { latLng = new LatLng(j, startPoint.longitude); } mCarMarker.setPosition(latLng);// 如果间隔时间小于1毫秒,则略过当前休眠,累加直到休眠时间到1毫秒:会损失精度if (mTimeInterval < 1) { mSleep += mTimeInterval;if (mSleep >= 1) { SystemClock.sleep((long) mSleep); mSleep = 0; } } else SystemClock.sleep((long) mTimeInterval); } } }}
实时轨迹数据排队问题
如果要显示实时轨迹怎么办 ,上面的代码就有问题了,Thread.start()
方法调用后,就会立马执行他的run
方法,run
方法执行完毕,线程也就结束了,也是说上面的代码只能跑一次轨迹数据,如果每间隔五秒从后台取一次轨迹数据,就需要一数据队列来存储这些数据,每跑完一次数据,就从数据队列里面去取,如果有就取来接着跑,如果没有就处于等待状态。
我们创建异步消息处理线程,这一问题就可以迎刃而解,来数据了我们就可以通过handler
把数据post
给我们的子线程,Handler自带数据队列,它处于排队状态,如果有数据了就开始跑轨迹,如果没有数据就处于等待状态,直到有数据的到来,如果对异步消息处理线程不熟悉,请查看我的另外一篇文章《Android实战开发Handler机制深度解析》https://menxindiaolong.blog.csdn.net/article/details/86560330
一个标准的异步消息处理线程应该怎么写?
方法1:
class LooperThread extends Thread {public Handler mHandler;
public void run() { Looper.prepare(); mHandler = new Handler() {public void handleMessage(Message msg) {// process incoming messages here } }; Looper.loop(); }}
方法2:
// Step 1: 创建并启动HandlerThread线程,内部包含Looper HandlerThread handlerThread = new HandlerThread("gityuan.com"); handlerThread.start();
// Step 2: 创建Handler Handler handler = new Handler(handlerThread.getLooper()) {public void handleMessage(Message msg) {// process incoming messages here } };// Step 3: 发送消息 handler.post(new Runnable() {@Overridepublic void run() { System.out.println("thread id="+Thread.currentThread().getId()); } });
上面就是Android系统中异步消息处理线程的通用写法
运动轨迹的暂停、继续问题
由于运动轨迹是在子线程里面完成的,我们自然而然会想到线程的等待、唤醒,也就是wait
、notify
的问题了 因此我们在运动过程加上就如下代码就可以了
if (pause) {try { lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } }}
怎么让他恢复运动呢?notify一下即可
public void reStartMove() {synchronized (lock) { pause = false; lock.notify(); }}
完整的代码如下:
public class MoveCarCustomThread extends Thread {public static final String TAG = MoveCarCustomThread.class.getSimpleName();private Handler moveCarHandler;//发送数据的异步消息处理器private Object lock = new Object();//线程锁private boolean moveing = false;//是否线程正在移动private boolean pause = false;//暂停状态,为true则暂停private boolean stop = false;//停止状态,为true则停止移动private WeakReference mActivityWeakReference;//防止内存Activity导致的内容泄漏private MOVE_STATE currMoveState = MOVE_STATE.START_STATUS;public void setCurrMoveState(MOVE_STATE currMoveState) {this.currMoveState = currMoveState; }public MOVE_STATE getCurrMoveState() {return currMoveState; }public MoveCarCustomThread(MainActivity activity) { mActivityWeakReference = new WeakReference<>(activity); }//暂停移动public void pauseMove() { pause = true; }//设置暂停之后,再次移动调用它public void reStartMove() {synchronized (lock) { pause = false; lock.notify(); } }public void stopMove() { stop = true;if(moveCarHandler != null){ moveCarHandler.removeCallbacksAndMessages(null); }if(mActivityWeakReference.get() != null){ mActivityWeakReference.get().mLatLngList.clear(); mActivityWeakReference.get().mMainHandler.removeCallbacksAndMessages(null); } }public Handler getMoveCarHandler() {return moveCarHandler; }public boolean isMoveing() {return moveing; }@Overridepublic void run() {super.run();//设置该线程为loop线程 Looper.prepare(); moveCarHandler = new Handler() {@Overridepublic void handleMessage(Message msg) {super.handleMessage(msg);//通过锁保证发过来的数据同步入列synchronized (lock) {if (msg.obj != null && msg.obj instanceof List) { List latLngList = (List) msg.obj; moveCoarseTrack(latLngList); } } } };//启动loop线程 Looper.loop(); }private void moveCoarseTrack(List latLngList) {if (latLngList == null || latLngList.size() == 0 || latLngList.size() == 1) {return; } Log.v(TAG, "moveCoarseTrack start.........................................................");long startTime = System.currentTimeMillis(); Log.v(TAG, "startTime:" + startTime);int step = TrackMoveUtil.getStep(latLngList);// 通过距离,计算轨迹动画运动步数 Log.v(TAG, "move step:" + step);float distance = TrackMoveUtil.getDistance(latLngList); Log.v(TAG, "move distance:" + distance);double mTimeInterval = TrackMoveUtil.getMoveTime(distance, step);// 通过距离,计算轨迹动画时间间隔 mTimeInterval = 10;// 每走一步停止10毫秒 Log.v(TAG, "move mTimeInterval:" + mTimeInterval); moveing = true;for (int i = 0; i < latLngList.size() - 1; i++) {// 暂停状态,线程停止了if (pause) { movePause(); }if (stop) {break; } moveing = true; LatLng startLatLng = latLngList.get(i); LatLng endLatLng = latLngList.get(i + 1); MainActivity mainActivity = mActivityWeakReference.get(); moveCar(startLatLng, endLatLng, mainActivity); moveLine(startLatLng, mainActivity); moveCamera(startLatLng, mainActivity);double slope = TrackMoveUtil.getSlope(startLatLng, endLatLng);// 计算两点间的斜率double intercept = TrackMoveUtil.getInterception(slope, startLatLng);// 根据点和斜率算取截距boolean isReverse = (startLatLng.latitude > endLatLng.latitude);// 是不是正向的标示(向上设为正向)double xMoveDistance = isReverse ? TrackMoveUtil.getXMoveDistance(slope) : -1 * TrackMoveUtil.getXMoveDistance(slope);// 应该对经纬度同时处理double sleep = 0;int flag = 0;for (double j = startLatLng.latitude; !((j >= endLatLng.latitude) ^ isReverse); j = j - xMoveDistance) {// 非暂停状态地图才进行跟随移动if (pause) { movePause(); }if (stop) {break; } moveing = true; flag++;if (slope != Double.MAX_VALUE) { startLatLng = new LatLng(j, (j - intercept) / slope); } else { startLatLng = new LatLng(j, startLatLng.longitude); } moveCar(startLatLng, mainActivity); moveLine(startLatLng, mainActivity);if (flag % 100 == 0) { moveCamera(startLatLng, mainActivity); }// 如果间隔时间小于1毫秒,则略过当前休眠,累加直到休眠时间到1毫秒:会损失精度if (mTimeInterval < 1) { sleep += mTimeInterval;if (sleep >= 1) { Log.v(TAG, "sleep:" + sleep); SystemClock.sleep((long) sleep); sleep = 0; } } else { SystemClock.sleep((long) mTimeInterval); } } }long endTime = System.currentTimeMillis(); moveing = false; Log.v(TAG, "endTime:" + endTime); Log.v(TAG, "run mTimeInterval:" + (endTime - startTime)); Log.v(TAG, "moveCoarseTrack end........................................................."); }private void moveLine(LatLng startLatLng, MainActivity mainActivity) { mainActivity.mLatLngList.add(startLatLng);// 向轨迹集合增加轨迹点 mainActivity.mMovePolyline.setPoints(mainActivity.mLatLngList);// 轨迹画线开始 }private void moveCar(LatLng startLatLng, LatLng endLatLng, MainActivity mainActivity) { moveCar(startLatLng,mainActivity);if (mainActivity.mCarMarker != null) { mainActivity.mCarMarker.setRotateAngle((float) TrackMoveUtil.getAngle(startLatLng, endLatLng));// 设置小车车头的方向 } }private void moveCar(LatLng startLatLng,MainActivity mainActivity) {if (mainActivity.mCarMarker != null) { mainActivity.mCarMarker.setPosition(startLatLng);// 小车移动 } }private void movePause() {try { lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } }private void moveCamera(LatLng startLatLng, MainActivity mainActivity) { Message message = Message.obtain(); message.what = MainActivity.EventType.MapMove; message.obj = startLatLng; mainActivity.mMainHandler.sendMessage(message); }}
核心算法工具类
*
轨迹平滑所需要的工具方法
Date: 2016-10-27 Created by mxdl
*/
public class TrackMoveUtil {
private static double DISTANCE = 0.0001;
/**
* 根据两点算斜率
*/
public static double getSlope(LatLng fromPoint, LatLng toPoint) {
if (fromPoint == null || toPoint == null) {
return 0;
}
if (toPoint.longitude == fromPoint.longitude) {
return Double.MAX_VALUE;
}
double slope =
((toPoint.latitude - fromPoint.latitude) / (toPoint.longitude - fromPoint.longitude));
return slope;
}
/**
* 根据两点算取图标转的角度
*/
public static double getAngle(LatLng fromPoint, LatLng toPoint) {
if (fromPoint == null || toPoint == null) {
return 0;
}
double slope = getSlope(fromPoint, toPoint);
if (slope == Double.MAX_VALUE) {
if (toPoint.latitude > fromPoint.latitude) {
return 0;
} else {
return 180;
}
}
float deltAngle = 0;
if ((toPoint.latitude - fromPoint.latitude) * slope < 0) {
deltAngle = 180;
}
double radio = Math.atan(slope);
double angle = 180 * (radio / Math.PI) + deltAngle - 90;
return angle;
}
/**
* 根据点和斜率算取截距
*/
public static double getInterception(double slope, LatLng point) {
if (point == null) {
return 0;
}
return point.latitude - slope * point.longitude;
}
/**
* 计算x方向每次移动的距离
*/
public static double getXMoveDistance(double slope) {
if (slope == Double.MAX_VALUE) {
return DISTANCE;
}
return Math.abs((DISTANCE * slope) / Math.sqrt(1 + slope * slope));
}
/**
* 根据轨迹线段计算小车走了多少步
*
* @param latLngList
* @return
*/
public static int getStep(List latLngList) {
int step = 0;
if (latLngList != null && latLngList.size() > 1) {
for (int i = 0; i < latLngList.size() - 1; i++) {
try {
LatLng startPoint = latLngList.get(i);
LatLng endPoint = latLngList.get(i + 1);
double slope = getSlope(startPoint, endPoint);
// 是不是正向的标示(向上设为正向)
boolean isReverse = (startPoint.latitude > endPoint.latitude);
double xMoveDistance = isReverse ? getXMoveDistance(slope) : -1 * getXMoveDistance(slope);
// 应该对经纬度同时处理
for (double j = startPoint.latitude; !((j >= endPoint.latitude) ^ isReverse); j =
j - xMoveDistance) {
step++;
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
return step;
}
/**
* 根据总距离和步数计算运动时间
*
* @param distance
* @param step
* @return
*/
public static double getMoveTime(float distance, int step) {
double timeInterval = 0;
if (distance > 0) {
float totalDistance = distance * 1000;
if (totalDistance <= 500) {
timeInterval = 1000.0 / step;
} else if (totalDistance > 500 && totalDistance <= 7500) {
timeInterval = 2.0 * totalDistance / step;
} else {
timeInterval = 15000.0 / step;
}
}
return timeInterval;
}
/**
* 根据轨迹点集合计算总距离
*
* @param latLngList
* @return
*/
public static float getDistance(List latLngList) {
float distance = 0;
if (latLngList != null && latLngList.size() > 1) {
for (int i = 0; i < latLngList.size() - 1; i++) {
try {
distance += AMapUtils.calculateLineDistance(latLngList.get(i), latLngList.get(i + 1));
} catch (Exception e) {
e.printStackTrace();
}
}
}
return distance;
}
// latitude - 地点的纬度,在-90 与90 之间的double 型数值。
// longitude - 地点的经度,在-180 与180 之间的double 型数值。
/**
* 根据一个经纬度字符串求一个经纬度集合a|b|c|d;
*
* @param latlonStr
* @return
*/
public static ListgetListLatLng(String latlonStr) {
if (!TextUtils.isEmpty(latlonStr)) {
String[] trackArr = latlonStr.split("\\|");
if (trackArr != null && trackArr.length > 0) {
List latLngList = new ArrayList();for (int i = 0; i < trackArr.length - 1; i = i + 2) {try {
String lat = trackArr[i + 1];
String lng = trackArr[i];// Logger.v(TAG,"trackArr index:" + i);// Logger.v(TAG,"trackArr lat:" + lat);// Logger.v(TAG,"trackArr lng:" + lng);if (!TextUtils.isEmpty(lat) && !TextUtils.isEmpty(lng)) {
Double dLat = Double.valueOf(lat);
Double dLng = Double.valueOf(lng);if (dLat >= -90 && dLat <= 90 && dLng >= -180 && dLng <= 180
&& !(dLat == 0 && dLng == 0)) {
LatLng latLng = new LatLng(dLat, dLng);
latLngList.add(latLng);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}return latLngList;
}
}return null;
}
}
高德API实现的实时运动轨迹代码:
public class MoveCarSmoothThread implements IMoveCar {public static final String TAG = MoveCarSmoothThread.class.getSimpleName();private MovingPointOverlay mMovingPointOverlay;private WeakReference mActivityWeakReference;private boolean isfirst = true;private MOVE_STATE currMoveState = MOVE_STATE.START_STATUS;public void setCurrMoveState(MOVE_STATE currMoveState) {this.currMoveState = currMoveState; }public MOVE_STATE getCurrMoveState() {return currMoveState; }public MoveCarSmoothThread(MainActivity activity) { mActivityWeakReference = new WeakReference<>(activity); }@Overridepublic void startMove(List latLngs) {if (latLngs == null || latLngs.size() == 0) {return; } Log.v("MYTAG","startMove start:"+Thread.currentThread().getName()); Log.v(TAG, "moveCoarseTrack start.........................................................");long startTime = System.currentTimeMillis(); Log.v(TAG, "startTime:" + startTime);final MainActivity mainActivity = mActivityWeakReference.get();if (mMovingPointOverlay == null) { mMovingPointOverlay = new MovingPointOverlay(mainActivity.mAMap, mainActivity.mCarMarker); mMovingPointOverlay.setTotalDuration(5); mMovingPointOverlay.setMoveListener(new MovingPointOverlay.MoveListener() {@Overridepublic void move(double v) {if(isfirst){ isfirst = false; Log.v("MYTAG","MoveCarSmoolthThread move start:"+Thread.currentThread().getName()); } LatLng position = mMovingPointOverlay.getPosition(); mainActivity.mLatLngList.add(position);// 向轨迹集合增加轨迹点 mainActivity.mMovePolyline.setPoints(mainActivity.mLatLngList);// 轨迹画线开始 Message message = Message.obtain(); message.what = MainActivity.EventType.MapMove; message.obj = position; message.arg1 = (int)v; mainActivity.mMainHandler.sendMessage(message); } }); } mMovingPointOverlay.setPoints(latLngs); mMovingPointOverlay.startSmoothMove();long endTime = System.currentTimeMillis(); Log.v(TAG, "endTime:" + endTime); Log.v(TAG, "moveCoarseTrack end........................................................."); }@Overridepublic void reStartMove() {if(mMovingPointOverlay != null){ mMovingPointOverlay.startSmoothMove(); } }@Overridepublic void pauseMove(){if(mMovingPointOverlay != null){ mMovingPointOverlay.stopMove(); } }@Overridepublic void stopMove(){if(mMovingPointOverlay != null){ mMovingPointOverlay.destroy(); mMovingPointOverlay = null; }if(mActivityWeakReference.get() != null){ mActivityWeakReference.get().mLatLngList.clear(); } }}
最后我把整个项目的的完整代码传到GitHub上了:https://github.com/geduo83/android-amap-movecar
---end---
推荐阅读:
七夕,不能送个对象,还不能送一本书吗?
一篇文章彻底明白Android文件存储
Android仿网易云音乐鲸云音效动效
每一个“在看”,我都当成真的喜欢
android double精度_Android车辆运动轨迹平滑移动(高仿滴滴打车)最佳实践相关推荐
- Android车辆运动轨迹平滑移动(高仿滴滴打车)最佳实践
版权声明:本文来自门心叼龙的博客,属于原创内容,转载请注明出处:https://menxindiaolong.blog.csdn.net/article/details/95789128 github ...
- Android 打造炫目的圆形菜单 秒秒钟高仿建行圆形菜单
转载请标明出处:http://blog.csdn.net/lmj623565791/article/details/43131133,本文出自:[张鸿洋的博客] 1.概述 今天打开建行看存款,一看伤心 ...
- android sudio jni 调用so_UE4:UPL 与 JNI 调用的最佳实践
本篇文章搬运自我自己的博客,原文链接: https://imzlp.me/posts/27289/ 作者: 查利鹏 在使用UE4开发Android时,有时需要获取平台相关的信息.或者执行平台相关的操作 ...
- Android开发笔记(一百六十三)高仿京东的沉浸式状态栏
前面的文章介绍了如何实现广告轮播的Banner效果,本想可以告一段落.然而某天产品经理心血来潮,拿着苹果手机,要求像iOS那样把广告图顶到状态栏这儿.刚接到这需求,不禁倒吸一口冷气,又要安卓开发去实现 ...
- Android侧滑返回分析和实现(不高仿微信),flutter项目实战
废话 不久前淘汰了用了三年多的iPhone6Plus,换了部三星S9+.流畅的吃鸡体验,丝滑的屏幕,超高的性价比(港行还另打了9折),真喜欢的不行.不过从IOS切换到Android,还是不太适应,首当 ...
- 高仿360云盘android端的ui实现,(原创)高仿360云盘android端的UI实现
前些日子几大互联网巨头展开了一轮网盘空间大战.一下子从G级别提高到了T级别.以后谁的空间没有1T估计都不好意思开口了~~~ 试用了一下360云盘的客户端,比较小清新(不是给360打广告~~~).刚好U ...
- [书目20131223]Android、iPhone、Windows Phone手机网页及网站设计:最佳实践与设计精粹 - 张亚飞...
目录 第I篇 手机版专用网站设计和开发入门篇 第1章 准备创作环境和测试环境 3 1.1 使用Mobile Safari测试网页 4 1.1.1 iOS Simulator安装 5 1.1.2 使用M ...
- (4.0.24.2)Android之桌面组件App Widget案例之高仿墨迹天气桌面组件
相信墨迹天气,大家都见过,他在时间显示和天气界面上,很吸引人,今天我就来模仿一下墨迹天气的桌面组件,但是由于谷歌在天朝频频被墙的缘故,所以我在今天测试的时候,解析xml文件的网页打不开,所以天气显示出 ...
- Android侧滑返回分析和实现(不高仿微信)
项目地址:SLWidget/SwipeBack Demo体验:SLWidget(1.5MB) 侧滑 屏幕旋转 窗口模式 废话 不久前淘汰了用了三年多的iPhone6Plus,换了部三星S9+.流畅的吃 ...
最新文章
- Java项目:图书管理系统(java+SSM+jsp+mysql+maven)
- vue 订单支付15分钟倒计时
- C++中字符型和整型的关系
- linux就业技术指导,学linux前景怎么样
- BlueTooth 蓝牙音频音质探讨
- shell 脚本比较字符串相等_LINUX快速入门第十六章:Shell 流程控制
- 大项目之网上书城(十二)——完成啦
- 网络游戏仅可在规定时间向未成年人提供1小时服务
- swagger访问开关配置
- 我摸鱼写的Java片段意外称霸Stack Overflow十年、征服6000多GitHub开源项目: 有bug!...
- 16.淘宝的架构演化案例
- ISCC2021-WP合集
- 软件测试模型有哪些?
- src 漏洞平台 应急响应中心 提交漏洞 简介
- LeetCode通关:哈希表六连,这个还真有点简单
- HSI、HSV、RGB、CMYK、HSL、HSB、Ycc、XYZ、Lab、YUV颜色模型的区别
- 还不明白什么是固件吗
- SpringCloud SpringBoot uniapp vue b2b2c 微服务 多商家入驻直播带货商城 分销商城 秒杀 高并发电商之责任链模式
- [源码和文档分享]基于JAVA实现的幼儿园信息管理系统
- Marin说PCB之POC电路layout设计总结