3D寻路系统NavMesh-服务端篇

本文涉及的产品
传统型负载均衡 CLB,每月750个小时 15LCU
网络型负载均衡 NLB,每月750个小时 15LCU
应用型负载均衡 ALB,每月750个小时 15LCU
简介: 3D寻路系统NavMesh-服务端篇

上一节讲到的客户端使用Unity 自带的 NavMesh 来做寻路 3D寻路系统NavMesh-客户端篇。然而,怪物的刷新、移动,和AI是由服务器负责的,怪物的寻路是由服务器控制的,或者像SLG,大地图寻路在玩家离线的情况下要继续寻路,这必须要服务器来主导寻路。

那么,这怎么去实现呢?

我们服务器必须要有一份导航网格的寻路数据。

用ExportSceneToObj 工具导出场景,这个好像不依赖于烘焙出来的导航网格。

//TODO 贴一下源码:

第一份源码是不依赖于导航网格的生成工具:

using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;
using UnityEngine.SceneManagement;
public class ExportScene : EditorWindow
{
    private const string CUT_LB_OBJ_PATH = "export/bound_lb";
    private const string CUT_RT_OBJ_PATH = "export/bound_rt";
    private static float autoCutMinX = 1000;
    private static float autoCutMaxX = 0;
    private static float autoCutMinY = 1000;
    private static float autoCutMaxY = 0;
    private static float cutMinX = 0;
    private static float cutMaxX = 0;
    private static float cutMinY = 0;
    private static float cutMaxY = 0;
    private static long startTime = 0;
    private static int totalCount = 0;
    private static int count = 0;
    private static int counter = 0;
    private static int progressUpdateInterval = 10000;
    [MenuItem("ExportScene/ExportSceneToObj")]
    [MenuItem("GameObject/ExportScene/ExportSceneToObj")]
    public static void Export()
    {
        ExportSceneToObj(false);
    }
    [MenuItem("ExportScene/ExportSceneToObj(AutoCut)")]
    [MenuItem("GameObject/ExportScene/ExportSceneToObj(AutoCut)")]
    public static void ExportAutoCut()
    {
        ExportSceneToObj(true);
    }
    [MenuItem("ExportScene/ExportSelectedObj")]
    [MenuItem("GameObject/ExportScene/ExportSelectedObj", priority = 44)]
    public static void ExportObj()
    {
        GameObject selectObj = Selection.activeGameObject;
        if (selectObj == null)
        {
            Debug.LogWarning("Select a GameObject");
            return;
        }
        string path = GetSavePath(false, selectObj);
        if (string.IsNullOrEmpty(path)) return;
        Terrain terrain = selectObj.GetComponent<Terrain>();
        MeshFilter[] mfs = selectObj.GetComponentsInChildren<MeshFilter>();
        SkinnedMeshRenderer[] smrs = selectObj.GetComponentsInChildren<SkinnedMeshRenderer>();
        Debug.Log(mfs.Length + "," + smrs.Length);
        ExportSceneToObj(path, terrain, mfs, smrs, false, false);
    }
    public static void ExportSceneToObj(bool autoCut)
    {
        string path = GetSavePath(autoCut, null);
        if (string.IsNullOrEmpty(path)) return;
        Terrain terrain = UnityEngine.Object.FindObjectOfType<Terrain>();
        MeshFilter[] mfs = UnityEngine.Object.FindObjectsOfType<MeshFilter>();
        SkinnedMeshRenderer[] smrs = UnityEngine.Object.FindObjectsOfType<SkinnedMeshRenderer>();
        ExportSceneToObj(path, terrain, mfs, smrs, autoCut, true);
    }
    public static void ExportSceneToObj(string path, Terrain terrain, MeshFilter[] mfs,
        SkinnedMeshRenderer[] smrs, bool autoCut, bool needCheckRect)
    {
        int vertexOffset = 0;
        string title = "export GameObject to .obj ...";
        StreamWriter writer = new StreamWriter(path);
        startTime = GetMsTime();
        UpdateCutRect(autoCut);
        counter = count = 0;
        progressUpdateInterval = 5;
        totalCount = (mfs.Length + smrs.Length) / progressUpdateInterval;
        foreach (var mf in mfs)
        {
            UpdateProgress(title);
            if (mf.GetComponent<Renderer>() != null &&
                (!needCheckRect || (needCheckRect && IsInCutRect(mf.gameObject))))
            {
                ExportMeshToObj(mf.gameObject, mf.sharedMesh, ref writer, ref vertexOffset);
            }
        }
        foreach (var smr in smrs)
        {
            UpdateProgress(title);
            if (!needCheckRect || (needCheckRect && IsInCutRect(smr.gameObject)))
            {
                ExportMeshToObj(smr.gameObject, smr.sharedMesh, ref writer, ref vertexOffset);
            }
        }
        if (terrain)
        {
            ExportTerrianToObj(terrain.terrainData, terrain.GetPosition(),
                ref writer, ref vertexOffset, autoCut);
        }
        writer.Close();
        EditorUtility.ClearProgressBar();
        long endTime = GetMsTime();
        float time = (float)(endTime - startTime) / 1000;
        Debug.Log("Export SUCCESS:" + path);
        Debug.Log("Export Time:" + time + "s");
        OpenDir(path);
    }
    private static void OpenDir(string path)
    {
        DirectoryInfo dir = Directory.GetParent(path);
        int index = path.LastIndexOf("/");
        OpenCmd("explorer.exe", dir.FullName);
    }
    private static void OpenCmd(string cmd, string args)
    {
        System.Diagnostics.Process.Start(cmd, args);
    }
    private static string GetSavePath(bool autoCut, GameObject selectObject)
    {
        string dataPath = Application.dataPath;
        string dir = dataPath.Substring(0, dataPath.LastIndexOf("/"));
        string sceneName = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name;
        string defaultName = "";
        if (selectObject == null)
        {
            defaultName = (autoCut ? sceneName + "(autoCut)" : sceneName);
        }
        else
        {
            defaultName = (autoCut ? selectObject.name + "(autoCut)" : selectObject.name);
        }
        return EditorUtility.SaveFilePanel("Export .obj file", dir, defaultName, "obj");
    }
    private static long GetMsTime()
    {
        return System.DateTime.Now.Ticks / 10000;
        //return (System.DateTime.Now.ToUniversalTime().Ticks - 621355968000000000) / 10000;
    }
    private static void UpdateCutRect(bool autoCut)
    {
        cutMinX = cutMaxX = cutMinY = cutMaxY = 0;
        if (!autoCut)
        {
            Vector3 lbPos = GetObjPos(CUT_LB_OBJ_PATH);
            Vector3 rtPos = GetObjPos(CUT_RT_OBJ_PATH);
            cutMinX = lbPos.x;
            cutMaxX = rtPos.x;
            cutMinY = lbPos.z;
            cutMaxY = rtPos.z;
        }
    }
    private static void UpdateAutoCutRect(Vector3 v)
    {
        if (v.x < autoCutMinX) autoCutMinX = v.x;
        if (v.x > autoCutMaxX) autoCutMaxX = v.x;
        if (v.z < autoCutMinY) autoCutMinY = v.z;
        if (v.z > autoCutMaxY) autoCutMaxY = v.z;
    }
    private static bool IsInCutRect(GameObject obj)
    {
        if (cutMinX == 0 && cutMaxX == 0 && cutMinY == 0 && cutMaxY == 0) return true;
        Vector3 pos = obj.transform.position;
        if (pos.x >= cutMinX && pos.x <= cutMaxX && pos.z >= cutMinY && pos.z <= cutMaxY)
            return true;
        else
            return false;
    }
    private static void ExportMeshToObj(GameObject obj, Mesh mesh, ref StreamWriter writer, ref int vertexOffset)
    {
        Quaternion r = obj.transform.localRotation;
        StringBuilder sb = new StringBuilder();
        foreach (Vector3 vertice in mesh.vertices)
        {
            Vector3 v = obj.transform.TransformPoint(vertice);
            UpdateAutoCutRect(v);
            sb.AppendFormat("v {0} {1} {2}\n", -v.x, v.y, v.z);
        }
        foreach (Vector3 nn in mesh.normals)
        {
            Vector3 v = r * nn;
            sb.AppendFormat("vn {0} {1} {2}\n", -v.x, -v.y, v.z);
        }
        foreach (Vector3 v in mesh.uv)
        {
            sb.AppendFormat("vt {0} {1}\n", v.x, v.y);
        }
        for (int i = 0; i < mesh.subMeshCount; i++)
        {
            int[] triangles = mesh.GetTriangles(i);
            for (int j = 0; j < triangles.Length; j += 3)
            {
                sb.AppendFormat("f {1} {0} {2}\n",
                    triangles[j] + 1 + vertexOffset,
                    triangles[j + 1] + 1 + vertexOffset,
                    triangles[j + 2] + 1 + vertexOffset);
            }
        }
        vertexOffset += mesh.vertices.Length;
        writer.Write(sb.ToString());
    }
    private static void ExportTerrianToObj(TerrainData terrain, Vector3 terrainPos,
        ref StreamWriter writer, ref int vertexOffset, bool autoCut)
    {
        int tw = terrain.heightmapResolution;
        int th = terrain.heightmapResolution;
        Vector3 meshScale = terrain.size;
        meshScale = new Vector3(meshScale.x / (tw - 1), meshScale.y, meshScale.z / (th - 1));
        Vector2 uvScale = new Vector2(1.0f / (tw - 1), 1.0f / (th - 1));
        Vector2 terrainBoundLB, terrainBoundRT;
        if (autoCut)
        {
            terrainBoundLB = GetTerrainBoundPos(new Vector3(autoCutMinX, 0, autoCutMinY), terrain, terrainPos);
            terrainBoundRT = GetTerrainBoundPos(new Vector3(autoCutMaxX, 0, autoCutMaxY), terrain, terrainPos);
        }
        else
        {
            terrainBoundLB = GetTerrainBoundPos(CUT_LB_OBJ_PATH, terrain, terrainPos);
            terrainBoundRT = GetTerrainBoundPos(CUT_RT_OBJ_PATH, terrain, terrainPos);
        }
        int bw = (int)(terrainBoundRT.x - terrainBoundLB.x);
        int bh = (int)(terrainBoundRT.y - terrainBoundLB.y);
        int w = bh != 0 && bh < th ? bh : th;
        int h = bw != 0 && bw < tw ? bw : tw;
        int startX = (int)terrainBoundLB.y;
        int startY = (int)terrainBoundLB.x;
        if (startX < 0) startX = 0;
        if (startY < 0) startY = 0;
        Debug.Log(string.Format("Terrian:tw={0},th={1},sw={2},sh={3},startX={4},startY={5}",
            tw, th, bw, bh, startX, startY));
        float[,] tData = terrain.GetHeights(0, 0, tw, th);
        Vector3[] tVertices = new Vector3[w * h];
        Vector2[] tUV = new Vector2[w * h];
        int[] tPolys = new int[(w - 1) * (h - 1) * 6];
        for (int y = 0; y < h; y++)
        {
            for (int x = 0; x < w; x++)
            {
                Vector3 pos = new Vector3(-(startY + y), tData[startX + x, startY + y], (startX + x));
                tVertices[y * w + x] = Vector3.Scale(meshScale, pos) + terrainPos;
                tUV[y * w + x] = Vector2.Scale(new Vector2(x, y), uvScale);
            }
        }
        int index = 0;
        for (int y = 0; y < h - 1; y++)
        {
            for (int x = 0; x < w - 1; x++)
            {
                tPolys[index++] = (y * w) + x;
                tPolys[index++] = ((y + 1) * w) + x;
                tPolys[index++] = (y * w) + x + 1;
                tPolys[index++] = ((y + 1) * w) + x;
                tPolys[index++] = ((y + 1) * w) + x + 1;
                tPolys[index++] = (y * w) + x + 1;
            }
        }
        count = counter = 0;
        progressUpdateInterval = 10000;
        totalCount = (tVertices.Length + tUV.Length + tPolys.Length / 3) / progressUpdateInterval;
        string title = "export Terrain to .obj ...";
        for (int i = 0; i < tVertices.Length; i++)
        {
            UpdateProgress(title);
            StringBuilder sb = new StringBuilder(22);
            sb.AppendFormat("v {0} {1} {2}\n", tVertices[i].x, tVertices[i].y, tVertices[i].z);
            writer.Write(sb.ToString());
        }
        for (int i = 0; i < tUV.Length; i++)
        {
            UpdateProgress(title);
            StringBuilder sb = new StringBuilder(20);
            sb.AppendFormat("vt {0} {1}\n", tUV[i].x, tUV[i].y);
            writer.Write(sb.ToString());
        }
        for (int i = 0; i < tPolys.Length; i += 3)
        {
            UpdateProgress(title);
            int x = tPolys[i] + 1 + vertexOffset; ;
            int y = tPolys[i + 1] + 1 + vertexOffset;
            int z = tPolys[i + 2] + 1 + vertexOffset;
            StringBuilder sb = new StringBuilder(30);
            sb.AppendFormat("f {0} {1} {2}\n", x, y, z);
            writer.Write(sb.ToString());
        }
        vertexOffset += tVertices.Length;
    }
    private static Vector2 GetTerrainBoundPos(string path, TerrainData terrain, Vector3 terrainPos)
    {
        var go = GameObject.Find(path);
        if (go)
        {
            Vector3 pos = go.transform.position;
            return GetTerrainBoundPos(pos, terrain, terrainPos);
        }
        return Vector2.zero;
    }
    private static Vector2 GetTerrainBoundPos(Vector3 worldPos, TerrainData terrain, Vector3 terrainPos)
    {
        Vector3 tpos = worldPos - terrainPos;
        return new Vector2((int)(tpos.x / terrain.size.x * terrain.heightmapResolution),
            (int)(tpos.z / terrain.size.z * terrain.heightmapResolution));
    }
    private static Vector3 GetObjPos(string path)
    {
        var go = GameObject.Find(path);
        if (go)
        {
            return go.transform.position;
        }
        return Vector3.zero;
    }
    private static void UpdateProgress(string title)
    {
        if (counter++ == progressUpdateInterval)
        {
            counter = 0;
            float process = Mathf.InverseLerp(0, totalCount, ++count);
            long currTime = GetMsTime();
            float sec = ((float)(currTime - startTime)) / 1000;
            string text = string.Format("{0}/{1}({2:f2} sec.)", count, totalCount, sec);
            EditorUtility.DisplayProgressBar(title, text, process);
        }
    }
}

第二份是要基于导航网格的,但生成的效果不大理想,所以没有采用:

/************************************************
 * 文件名:ExportNavMesh.cs
 * 描述:导出NavMesh数据给服务器使用
 * ************************************************/
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using System.IO;
using UnityEngine.AI;
using UnityEngine.SceneManagement;
public class ExportNavMesh
{
    [MenuItem("NavMesh/Export")]
    static void Export()
    {
        Debug.Log("ExportNavMesh");
        NavMeshTriangulation tmpNavMeshTriangulation = NavMesh.CalculateTriangulation();
        //新建文件
        string tmpPath = Application.dataPath + "/" + UnityEngine.SceneManagement.SceneManager.GetActiveScene().name + ".obj";
        StreamWriter tmpStreamWriter = new StreamWriter(tmpPath);
        //顶点
        for (int i = 0; i < tmpNavMeshTriangulation.vertices.Length; i++)
        {
            tmpStreamWriter.WriteLine("v  " + tmpNavMeshTriangulation.vertices[i].x + " " +
                                      tmpNavMeshTriangulation.vertices[i].y + " " +
                                      tmpNavMeshTriangulation.vertices[i].z);
        }
        tmpStreamWriter.WriteLine("g pPlane1");
        //索引
        for (int i = 0; i < tmpNavMeshTriangulation.indices.Length;)
        {
            tmpStreamWriter.WriteLine("f " + (tmpNavMeshTriangulation.indices[i] + 1) + " " +
                                      (tmpNavMeshTriangulation.indices[i + 1] + 1) + " " +
                                      (tmpNavMeshTriangulation.indices[i + 2] + 1));
            i = i + 3;
        }
        tmpStreamWriter.Flush();
        tmpStreamWriter.Close();
        Debug.Log("ExportNavMesh Success");
    }
}

选中场景,选择菜单,ExportScene–>ExportSelectedObj,会生成一个Main Terrain.obj文件,大概20几M.

然后用recast 工具打开:

选择solomesh,

调整Agent下Radius参数,与当前项目中 最大寻路单位的半径 保持一致,点击 Build ,等待一段时间后,中间渲染图形会显示生成后的寻路网格,

蓝色地块就是可通点,可以尝试寻路一下。原始的recast是没有开始点和结束点的坐标的,那如何能显示出来呢?

void NavMeshTesterTool::handleRenderOverlay(double* proj, double* model, int* view)
{
  GLdouble x, y, z;
  char buf[64];
  // Draw start and end point labels
  if (m_sposSet && gluProject((GLdouble)m_spos[0], (GLdouble)m_spos[1], (GLdouble)m_spos[2],
    model, proj, view, &x, &y, &z))
  {
    if (m_showCoord)
    {
      snprintf(buf, sizeof(buf), "Start (%.1f, %.1f, %.1f)", m_spos[0], m_spos[1], m_spos[2]);
      imguiDrawText((int)x, (int)(y - 25), IMGUI_ALIGN_CENTER, buf, imguiRGBA(0, 0, 0, 220));
    }
    else
      imguiDrawText((int)x, (int)(y - 25), IMGUI_ALIGN_CENTER, "Start", imguiRGBA(0, 0, 0, 220));
  }
  if (m_toolMode == TOOLMODE_RAYCAST && m_hitResult && m_showCoord &&
    gluProject((GLdouble)m_hitPos[0], (GLdouble)m_hitPos[1], (GLdouble)m_hitPos[2],
      model, proj, view, &x, &y, &z))
  {
    snprintf(buf, sizeof(buf), "HitPos (%.1f, %.1f, %.1f)", m_hitPos[0], m_hitPos[1], m_hitPos[2]);
    imguiDrawText((int)x, (int)(y - 25), IMGUI_ALIGN_CENTER, buf, imguiRGBA(0, 0, 0, 220));
  }
  if (m_eposSet && gluProject((GLdouble)m_epos[0], (GLdouble)m_epos[1], (GLdouble)m_epos[2],
    model, proj, view, &x, &y, &z))
  {
    if (m_showCoord)
    {
      float totalCost = 0.0f;
      for (int i = 0; i + 1 < m_nstraightPath; i++)
        totalCost += dtVdist(&m_straightPath[i * 3], &m_straightPath[(i + 1) * 3]);
      snprintf(buf, sizeof(buf), "End (%.1f, %.1f, %.1f), Cost %.1f", m_epos[0], m_epos[1], m_epos[2], totalCost);
      imguiDrawText((int)x, (int)(y - 25), IMGUI_ALIGN_CENTER, buf, imguiRGBA(0, 0, 0, 220));
    }
    else
      imguiDrawText((int)x, (int)(y - 25), IMGUI_ALIGN_CENTER, "End", imguiRGBA(0, 0, 0, 220));
  }
}

那么,如何能显示出关键点point list?,首先,路径搜索的模式要改成TOOLMODE_PATHFIND_STRAIGHT模式,代码需要增加如下的打印,

在NavMeshTesterTool.cpp中增加,

void NavMeshTesterTool::recalc(){
....
....
  if (m_toolMode == TOOLMODE_PATHFIND_STRAIGHT) {
    m_sample->getContext()->log(RC_LOG_PROGRESS, "total point size=%d", m_nstraightPath);
    for (int i = 0; i < m_nstraightPath; ++i)
    {
      m_sample->getContext()->log(RC_LOG_PROGRESS, "(%.1f, %.1f, %.1f)", m_straightPath[i * 3], m_straightPath[i * 3 + 1], m_straightPath[i * 3 + 2]);
    }
  }
}

在Sample.h中增加

public:
  Sample();
  virtual ~Sample();
  void setContext(BuildContext* ctx) { m_ctx = ctx; }
  BuildContext* getContext() {
    return m_ctx;
  }

寻路结果为:

这里要提一下,这里导出的坐标是以左手坐标系,也就是(x,y,z),这和unity一致,实际上客服端发给服务器的是x,z 当做坐标点,这点要特别注意, 左手坐标系是什么样的?如图所示

点击 save ,会在当前目录下生成【导出文件】 solo_navmesh.bin,只有156KB,就是服务器导航需要的bin文件。

引入第三方库:

基于recast4j封装的Java版本3D游戏寻路组件

<dependency>
  <groupId>com.github.silencesu</groupId>
  <artifactId>Easy3dNav</artifactId>
  <version>1.1.0</version>
</dependency>
Easy3dNav   javaNav = new Easy3dNav();
        javaNav.setPrintMeshInfo(false);
        javaNav.setUseU3dData(false);
        javaNav .init(id, "../../../../game_data/server/navmesh/solo_navmesh.bin");
       List<float[]> array = javaNav.find(new float[]{-107.4f, 0f, 148.5f}, new float[]{-51.7f, 0.2f, 150f}, new float[]{1.f, 1.f, 1.f});

执行结果为:

point 5 array=[[-107.4, 0.3209684, 148.5], [-64.09999, 1.2, 137.40001], [-56.299988, 0.6, 139.8], [-54.5, 0.6, 142.5], [-51.7, 0.4, 150.0]]

与工具生成的一致。

以上是JAVA版本的服务器寻路,如何用JNI制作更高性能的寻路包呢,下一篇文章中会做说明。

如果用工具寻路出来的数据和程序寻路出来的数据一致,说明navmesh寻路成功。

接下去就是动态寻路,优化寻路结果。

相关参考:

ExportSceneToObj

Recast & Detour

将Unity场景(包含物件和地形)导出到.obj文件

Easy3dNav

相关实践学习
SLB负载均衡实践
本场景通过使用阿里云负载均衡 SLB 以及对负载均衡 SLB 后端服务器 ECS 的权重进行修改,快速解决服务器响应速度慢的问题
负载均衡入门与产品使用指南
负载均衡(Server Load Balancer)是对多台云服务器进行流量分发的负载均衡服务,可以通过流量分发扩展应用系统对外的服务能力,通过消除单点故障提升应用系统的可用性。 本课程主要介绍负载均衡的相关技术以及阿里云负载均衡产品的使用方法。
目录
相关文章
|
12月前
|
人工智能 定位技术 图形学
3D寻路系统NavMesh-客户端篇
3D寻路系统NavMesh-客户端篇
510 0
|
12月前
|
监控 算法 Java
在服务器端如何用JNI实现 NavMesh寻路
在服务器端如何用JNI实现 NavMesh寻路
110 0
|
机器学习/深度学习 传感器 算法
【具有路由 WSN 模拟器的随机方式移动】具有路由 WSN 模拟器的随机方式移动(Matlab代码实现)
【具有路由 WSN 模拟器的随机方式移动】具有路由 WSN 模拟器的随机方式移动(Matlab代码实现)
|
算法 5G
一种用于环境声源的被动到达角(AoA)提取算法(Matlab代码实现)
一种用于环境声源的被动到达角(AoA)提取算法(Matlab代码实现)
114 0
|
网络协议 测试技术 Go
服务端转发消息思路分析|学习笔记
快速学习服务端转发消息思路分析
|
JSON 网络协议 测试技术
客户端发消息思路分析|学习笔记
快速学习客户端发消息思路分析
|
XML 前端开发 Java
从零开始实现放置游戏(十)——实现战斗挂机(1)hessian服务端搭建
 前面实现RMS系统时,我们让其直接访问底层数据库。后面我们在idlewow-game模块实现游戏逻辑时,将不再直接访问底层数据,而是通过hessian服务暴露接口给表现层。   本章,我们先把hessian服务搭好,并做一个简单的测试,这里以用户注册接口为例。   先简单介绍下,实现hessian接口,只需要在facade模块暴露接口,然后在core模块实现接口,最后在hessain模块配置好接口路由,将其启动即可。
从零开始实现放置游戏(十)——实现战斗挂机(1)hessian服务端搭建
|
安全 容灾
干货分享:全面解析什么是MTP/MPO预端接高密度配线
MTP/MPO系列是一组极具创新性的产品,它使光纤传输进入了一个新纪元。MTP/MPO系列是容灾、主干网、建筑物内光纤分布等多种应用的理想产品,例如,一个MTP/MPO连接器就能实现一根光缆上的12芯传输。
1865 0
|
编解码 前端开发 缓存
浅析直播间搭建过程中传输前端的优化问题
在直播间搭建过程中,优化可以说是一个非常重要且普遍的问题。其中,优化还可以细分为:传输前端和传输后端。今天主要跟大家分享的是传输前端的优化问题,因为传输的前端也就是主播端,在主播端最需要解决的就是推流器问题。
|
iOS开发
AirPods的自动连接配对原理
版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/voidreturn/article/details/81672275 首次连接 打开装有 AirPods 的充电盒,并将它放在 iPhone 旁边。
4742 0