Browse Source

refactor(更新) :
- 开关状态页面优化

周文健 3 months ago
parent
commit
c5f9c663ad

+ 9 - 29
app/src/main/java/com/grkj/iscs_mars/ble/BleConnectionManager.kt

@@ -88,8 +88,8 @@ object BleConnectionManager {
             unregisterConnectListener(mac)
         }
         // 重复注册检查
-        if (connectListeners.any { it.mac == mac } || currentConnectingMac.contains(mac)) {
-            LogUtil.w("蓝牙连接-忽略重复注册 mac: $mac")
+        if (currentConnectingMac.contains(mac)) {
+            LogUtil.w("忽略重复注册 mac: $mac")
             callBack?.invoke(false, null)
             return
         }
@@ -168,19 +168,19 @@ object BleConnectionManager {
                 isPreparing = false
                 currentConnectingMac.removeIf { it == listener.mac }
                 if (!isDone) {
-                    // 判断是否仍然待连,防止拿走;移到末尾,防止循环影响
-                    if (checkProcess(listener.mac, false)) {
-                        listener.callBack?.invoke(false, null)
-                        unregisterConnectListener(listener.mac)
-                    }
-                    LogUtil.i("蓝牙连接-连接钥匙失败")
+                    listener.callBack?.invoke(false, null)
+                    unregisterConnectListener(
+                        listener.mac
+                    )
                     return@runOnMain
                 }
                 // 判断是否仍然待连,防止拿走
                 // TODO 暂时只处理准备成功
                 if (connectListeners.contains(listener)) {
                     listener.callBack?.invoke(true, bleBean)
-                    unregisterConnectListener(listener.mac)
+                    unregisterConnectListener(
+                        listener.mac
+                    )
                 }
                 if (connectListeners.isNotEmpty()) connectKey()
             }
@@ -197,10 +197,6 @@ object BleConnectionManager {
         isNeedLoading: Boolean = false,
         prepareDoneCallBack: ((Boolean, BleBean?) -> Unit)?
     ) {
-        if (!checkProcess(mac, false)) {
-            LogUtil.e("蓝牙连接-Prepare is canceled : $mac")
-            return
-        }
         Executor.runOnMain {
             CommonUtils.checkBlePermission(activity) {
                 doScanBle(mac, isNeedLoading, prepareDoneCallBack)
@@ -215,10 +211,6 @@ object BleConnectionManager {
         prepareDoneCallBack: ((Boolean, BleBean?) -> Unit)?
     ) {
         LogUtil.i("蓝牙连接-doScanBle:$mac")
-        if (!checkProcess(mac, false)) {
-            LogUtil.e("蓝牙连接-Prepare is canceled : $mac")
-            return
-        }
         if (isNeedLoading) sendEventMsg(
             MsgEvent(
                 MSG_EVENT_LOADING, LoadingMsg(true, "正在扫描设备...", null)
@@ -281,10 +273,6 @@ object BleConnectionManager {
         prepareDoneCallBack: ((Boolean, BleBean?) -> Unit)?
     ) {
         LogUtil.i("蓝牙连接-doConnect : ${bleDevice.mac}")
-        if (!checkProcess(bleDevice.mac, false)) {
-            LogUtil.e("蓝牙连接-Prepare is canceled : ${bleDevice.mac}")
-            return
-        }
         if (isNeedLoading) sendEventMsg(
             MsgEvent(
                 MSG_EVENT_LOADING,
@@ -340,10 +328,6 @@ object BleConnectionManager {
                             removeExceptionKey(it.mac)
                             // 设置MTU
                             Executor.delayOnMain(200) {
-                                if (!checkProcess(bleDevice.mac, false)) {
-                                    LogUtil.e("Prepare is canceled : ${bleDevice.mac}")
-                                    return@delayOnMain
-                                }
                                 BleUtil.instance?.setMtu(it)
                             }
                             // 监听
@@ -404,10 +388,6 @@ object BleConnectionManager {
         isNeedLoading: Boolean = false,
         prepareDoneCallBack: ((Boolean, BleBean?) -> Unit)?
     ) {
-        if (!checkProcess(bleBean?.bleDevice?.mac, false)) {
-            LogUtil.e("蓝牙连接-Prepare is canceled : ${bleBean?.bleDevice?.mac}")
-            return
-        }
         if (isNeedLoading) sendEventMsg(
             MsgEvent(
                 MSG_EVENT_LOADING, LoadingMsg(true, "开始监听...", null)

+ 140 - 42
app/src/main/java/com/grkj/iscs_mars/util/BitmapUtil.kt

@@ -7,47 +7,168 @@ import android.graphics.Canvas
 import android.graphics.ImageFormat
 import android.graphics.Rect
 import android.graphics.YuvImage
+import android.graphics.drawable.Drawable
 import android.os.Environment
+import androidx.collection.LruCache
 import com.bumptech.glide.Glide
+import com.bumptech.glide.Priority
 import com.bumptech.glide.load.DataSource
+import com.bumptech.glide.load.DecodeFormat
+import com.bumptech.glide.load.engine.DiskCacheStrategy
 import com.bumptech.glide.load.engine.GlideException
+import com.bumptech.glide.request.FutureTarget
 import com.bumptech.glide.request.RequestListener
+import com.bumptech.glide.request.target.CustomTarget
 import com.bumptech.glide.request.target.Target
+import com.bumptech.glide.request.transition.Transition
 import com.grkj.iscs_mars.util.log.LogUtil
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
 import java.io.ByteArrayOutputStream
 import java.io.File
 import java.io.FileOutputStream
 import java.io.IOException
+import kotlin.math.max
 
-
+/**
+ * 通用图片工具:
+ * 1. downloadToFile   —— 仅下载,不解码;大底图专用
+ * 2. readImageSize    —— inJustDecodeBounds 探尺寸,不占内存
+ * 3. decodePreview    —— 解 1/N 低清图,避免首屏白板
+ * 4. loadBitmapSmall  —— 小图标下载 + Lru 缓存 + RGB_565
+ *
+ * 注意:方法名前带 `suspend` 的全部在 IO 线程运行,无需再切协程。
+ */
 object BitmapUtil {
+    /*--------------------------------------------------------*/
+    /*----------- 1. 仅下载到缓存目录(不解 Bitmap) -----------*/
+    /*--------------------------------------------------------*/
 
-    fun bitmapToFile(bitmap: Bitmap, fileName: String): File? {
-        // 创建一个临时文件
-        val storageDir =
-            Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
-        if (!storageDir.exists()) {
-            storageDir.mkdirs()
+    /**
+     * 异步下载网络图片到 Glide 自带磁盘缓存目录并返回 File。
+     *
+     * @return 缓存文件;失败时返回 null
+     */
+    suspend fun downloadToFile(ctx: Context, url: String): File? =
+        withContext(Dispatchers.IO) {
+            try {
+                val future: FutureTarget<File> = Glide.with(ctx)
+                    .downloadOnly()                     // 关键:只下载不解码
+                    .load(url)
+                    .priority(Priority.IMMEDIATE)
+                    .submit()                           // SIZE_ORIGINAL
+                future.get()                           // 阻塞等待
+            } catch (t: Throwable) {
+                t.printStackTrace()
+                null
+            }
         }
 
-        val file = File(storageDir, fileName)
+    /*--------------------------------------------------------*/
+    /*----------- 2. 只读取宽高(BitmapFactory 探针)-----------*/
+    /*--------------------------------------------------------*/
 
+    /**
+     * @return Pair<width, height>;读取失败返回 (0, 0)
+     */
+    fun readImageSize(file: File): Pair<Int, Int> {
         return try {
-            // 创建文件输出流
-            val fos = FileOutputStream(file)
+            val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+            BitmapFactory.decodeFile(file.absolutePath, opts)
+            opts.outWidth to opts.outHeight
+        } catch (e: Exception) {
+            e.printStackTrace()
+            0 to 0
+        }
+    }
+
+    /*--------------------------------------------------------*/
+    /*----------- 3. 首屏低清预览(RGB_565 + 采样)------------*/
+    /*--------------------------------------------------------*/
 
-            // 将 Bitmap 压缩并写入文件
-            bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
+    /**
+     * 从大图文件快速生成一张缩略图(最长边 ≈ maxSide)。
+     *
+     * @param maxSide 默认 1024,即最长边 ≤ 1024 px
+     */
+    fun decodePreview(file: File, maxSide: Int = 1024): Bitmap {
+        val (w, h) = readImageSize(file)
+        // 采样倍数至少为 1;向下取整保证 decode 后尺寸 ≤ maxSide
+        val sample = max(1, max(w, h) / maxSide)
+        val opts = BitmapFactory.Options().apply {
+            inSampleSize = sample
+            inPreferredConfig = Bitmap.Config.RGB_565   // 省内存
+        }
+        return BitmapFactory.decodeFile(file.absolutePath, opts)
+    }
 
-            // 刷新并关闭输出流
-            fos.flush()
-            fos.close()
+    /*--------------------------------------------------------*/
+    /*----------- 4. 小图加载(Glide + Lru 内存缓存)----------*/
+    /*--------------------------------------------------------*/
 
-            file
-        } catch (e: IOException) {
-            e.printStackTrace()
-            null
+    /** 内存缓存:size = 4 MB (按 byteCount 统计) */
+    private val smallCache = object : LruCache<String, Bitmap>(4 * 1024 * 1024) {
+        override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount / 1024
+    }
+
+    /**
+     * 4‑A:**非阻塞回调版** —— UI 层用这个最方便
+     */
+    fun loadBitmapSmall(
+        ctx: Context,
+        url: String?,
+        reqW: Int,
+        reqH: Int,
+        placeholder: Int? = null,
+        cb: (Bitmap?) -> Unit
+    ) {
+        if (url.isNullOrEmpty()) {
+            cb(null); return
         }
+        smallCache[url]?.let { cb(it); return }
+
+        Glide.with(ctx)
+            .asBitmap()
+            .load(url)
+            .override(reqW, reqH)
+            .format(DecodeFormat.PREFER_RGB_565)
+            .diskCacheStrategy(DiskCacheStrategy.ALL)
+            .apply { placeholder?.let { placeholder(it) } }
+            .into(object : CustomTarget<Bitmap>() {
+                override fun onResourceReady(
+                    resource: Bitmap,
+                    transition: Transition<in Bitmap>?
+                ) {
+                    smallCache.put(url, resource)
+                    cb(resource)
+                }
+
+                override fun onLoadFailed(errorDrawable: Drawable?) = cb(null)
+                override fun onLoadCleared(placeholder: Drawable?) {}
+            })
+    }
+
+    /**
+     * 4‑B:**挂起函数版** —— 需要在协程里同步拿 Bitmap 可用
+     */
+    suspend fun loadBitmapSmallSuspend(
+        ctx: Context,
+        url: String,
+        reqW: Int,
+        reqH: Int
+    ): Bitmap? = withContext(Dispatchers.IO) {
+        smallCache[url]?.let { return@withContext it }
+
+        runCatching {
+            val future: FutureTarget<Bitmap> = Glide.with(ctx)
+                .asBitmap()
+                .load(url)
+                .override(reqW, reqH)
+                .format(DecodeFormat.PREFER_RGB_565)
+                .diskCacheStrategy(DiskCacheStrategy.ALL)
+                .submit()
+            future.get().also { smallCache.put(url, it) }
+        }.getOrNull()
     }
 
     fun bitmapToByteArray(
@@ -189,27 +310,4 @@ object BitmapUtil {
 
         return bitmap
     }
-
-    /**
-     * 将NV21格式的数据转换为Bitmap。(效率低,用NV21ToBitmap类)
-     *
-     * @param nv21 NV21格式的字节数组
-     * @param width 图像的宽度
-     * @param height 图像的高度
-     * @return 转换后的Bitmap对象
-     */
-    fun convertNV21ToBitmap(nv21: ByteArray?, width: Int, height: Int): Bitmap? {
-        val yuvImage = YuvImage(nv21, ImageFormat.NV21, width, height, null)
-        val outputStream = ByteArrayOutputStream()
-        yuvImage.compressToJpeg(Rect(0, 0, width, height), 100, outputStream)
-        val imageBytes = outputStream.toByteArray()
-        return try {
-            val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
-            outputStream.close()
-            bitmap
-        } catch (e: Exception) {
-            e.printStackTrace()
-            null
-        }
-    }
 }

+ 37 - 0
app/src/main/java/com/grkj/iscs_mars/util/MapLayerExt.kt

@@ -0,0 +1,37 @@
+package com.grkj.iscs_mars.util
+
+import com.onlylemi.mapview.library.MapView
+import com.onlylemi.mapview.library.layer.MapBaseLayer
+
+/**
+ * MapView 图层管理扩展。
+ *
+ * 用法:
+ * ```kotlin
+ * val tileLayer = RegionTileLayer(mapView, bigImageFile)
+ * mapView.replaceLayer("bigMap", tileLayer)
+ * ```
+ *
+ * - `tag` 用于标识唯一业务层;再次调用时会自动替换旧层。
+ * - 新层默认插入到索引 0(最底层),保持:底图 → 业务层 → 叠加层 的绘制顺序。
+ */
+fun MapView.replaceLayer(tag: String, newLayer: MapBaseLayer) {
+    // 为新层打 tag,方便后续查找
+    newLayer.tag = tag
+
+    // 1. 查找并移除同 tag 的旧层
+    val itr = layers.iterator()
+    while (itr.hasNext()) {
+        val layer = itr.next()
+        if (layer.tag == tag) {
+            itr.remove()
+            break
+        }
+    }
+
+    // 2. 插入最底层(index 0)。如需不同顺序可自行调整索引。
+    layers.add(0, newLayer)
+
+    // 3. 请求重绘
+    postInvalidate()
+}

+ 14 - 4
app/src/main/java/com/grkj/iscs_mars/view/activity/SwitchStatusActivity.kt

@@ -24,6 +24,7 @@ import com.grkj.iscs_mars.view.presenter.SwitchStatusPresenter
 import com.grkj.iscs_mars.view.widget.CustomSwitchStationLayer
 import com.onlylemi.mapview.library.MapViewListener
 import com.sik.sikcore.extension.setDebouncedClickListener
+import com.sik.sikcore.thread.ThreadUtils
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
 
@@ -79,7 +80,7 @@ class SwitchStatusActivity :
     private fun BindingAdapter.BindingViewHolder.onRVListBinding() {
         val itemBinding = getBinding<ItemSwitchBinding>()
         val item = getModel<IsMapPoint>()
-        itemBinding.index.text = "${modelPosition + 1}"
+        itemBinding.index.text = "${item.pointSerialNumber}"
         itemBinding.pointName.text = item.entityName
         val switchData = ModBusController.getSwitchData()
         val switchStatus = switchData
@@ -90,7 +91,7 @@ class SwitchStatusActivity :
             getString(R.string.switch_close)
         }
         itemBinding.position.setDebouncedClickListener {
-
+            stationLayer?.selectPoint(item.pointSerialNumber.toString())
         }
     }
 
@@ -104,8 +105,17 @@ class SwitchStatusActivity :
     private fun getMap(mapId: String) {
         presenter?.getMapInfo(mapId.toLong()) { itMapInfo ->
             mBinding?.rvList?.models = itMapInfo?.pointList
-            presenter?.mapDataHandle(this, itMapInfo, stationLayer) { mapBmp ->
-                mBinding?.mapview?.loadMap(mapBmp)
+            ThreadUtils.runOnIO {
+                presenter?.mapDataHandle(
+                    this@SwitchStatusActivity,
+                    itMapInfo,
+                    { mBinding?.mapview },
+                    stationLayer
+                ) { mapBmp ->
+                    ThreadUtils.runOnMain {
+                        mBinding?.mapview?.loadMap(mapBmp)
+                    }
+                }
             }
         }
     }

+ 77 - 56
app/src/main/java/com/grkj/iscs_mars/view/presenter/SwitchStatusPresenter.kt

@@ -14,9 +14,12 @@ import com.grkj.iscs_mars.util.BitmapUtil
 import com.grkj.iscs_mars.util.Executor
 import com.grkj.iscs_mars.util.NetApi
 import com.grkj.iscs_mars.util.log.LogUtil
+import com.grkj.iscs_mars.util.replaceLayer
 import com.grkj.iscs_mars.view.base.BasePresenter
 import com.grkj.iscs_mars.view.iview.ISwitchStatusView
 import com.grkj.iscs_mars.view.widget.CustomSwitchStationLayer
+import com.grkj.iscs_mars.view.widget.RegionTileLayer
+import com.onlylemi.mapview.library.MapView
 
 class SwitchStatusPresenter : BasePresenter<ISwitchStatusView>() {
     /**
@@ -33,8 +36,13 @@ class SwitchStatusPresenter : BasePresenter<ISwitchStatusView>() {
      * 地图id
      */
     fun getMapInfo(mapId: Long, callBack: (MapInfoRespVO?) -> Unit) {
-        callBack(Gson().fromJson<BaseVO<MapInfoRespVO>>(SimData.mapData,object : TypeToken<BaseVO<MapInfoRespVO>>(){}.type).data)
-        return
+//        callBack(
+//            Gson().fromJson<BaseVO<MapInfoRespVO>>(
+//                SimData.mapData,
+//                object : TypeToken<BaseVO<MapInfoRespVO>>() {}.type
+//            ).data
+//        )
+//        return
         NetApi.getMapInfo(mapId) {
             Executor.runOnMain {
                 callBack(it)
@@ -43,69 +51,82 @@ class SwitchStatusPresenter : BasePresenter<ISwitchStatusView>() {
     }
 
     /**
-     * 地图数据处理
+     * 处理地图大图 + 点位
+     *
+     * @param context      上下文
+     * @param itMapInfo    后端返回的地图信息
+     * @param mapViewRef   MapView 的弱引用 —— 仅用来塞 RegionTileLayer(避免循环依赖,你也可直接传 MapView)
+     * @param stationLayer 点位层(用于写入 IsolationPoint 列表)
+     * @param onPreview    首屏低清预览回调(Bitmap)
      */
-    fun mapDataHandle(
+    suspend fun mapDataHandle(
         context: Context,
         itMapInfo: MapInfoRespVO?,
+        mapViewRef: () -> MapView?,                     // <-- 自己决定怎么拿 MapView
         stationLayer: CustomSwitchStationLayer?,
-        callback: (Bitmap) -> Unit
-    ){
-        // 如果没有图 URL,直接返回
-        val imageUrl = itMapInfo?.imageUrl ?: return
+        onPreview: (Bitmap) -> Unit
+    ) {
+        val imageUrl = itMapInfo?.imageUrl ?: return          // 没图 URL 直接溜了
 
-        BitmapUtil.loadBitmapFromUrl(context, imageUrl) { mapBmp ->
-            if (mapBmp == null) {
-                LogUtil.e("Map pic is null")
-                return@loadBitmapFromUrl
-            }
-            // 清空旧点
-            mStationList.clear()
-            // 1 格 对应的像素
-            val cellPx = 50f
-            // 后端给的“逻辑”子图原始尺寸(像素)
-            val backendW = itMapInfo.width!!.toFloat()
-            val backendH = itMapInfo.height!!.toFloat()
-            // 实际下载回来的 Bitmap 尺寸(像素)
-            val actualW = mapBmp.width.toFloat()
-            val actualH = mapBmp.height.toFloat()
-            // 计算缩放比例
-            val ratioX = actualW / backendW
-            val ratioY = actualH / backendH
-            mapRatio = ratioX
-            // 子图在全局坐标系里的左上角偏移(像素)
-            val offsetX = itMapInfo.x!!.toFloat()
-            val offsetY = itMapInfo.y!!.toFloat()
-            itMapInfo.pointList?.filter { it.x != null && it.y != null }?.forEach { pt ->
-                // 1) 格数 → 全局像素
-                val globalX = pt.x!!.toFloat() * cellPx
-                val globalY = pt.y!!.toFloat() * cellPx
-                // 2) 全局像素 - 子图偏移 = 子图内像素
-                val localX = globalX - offsetX
-                val localY = globalY - offsetY
-                // 3) 再乘缩放比,得到真实 Bitmap 上的像素坐标
-                val finalX = localX * ratioX
-                val finalY = localY * ratioY
-                // 异步加载点位图标,固定请求尺寸
-                mStationList.add(
-                    CustomSwitchStationLayer.IsolationPoint(
-                        PointF(finalX, finalY),
-                        pt.entityName!!,
-                        null,
-                        pt.entityId!!.toLong(),
-                        pt.pointSerialNumber,
-                        false
-                    )
+        /* --- 1. 下载文件,不解码 --- */
+        val imgFile = BitmapUtil.downloadToFile(context, imageUrl) ?: run {
+            LogUtil.e("Map download failed → $imageUrl"); return
+        }
+
+        /* --- 2. 快速首屏预览 --- */
+        BitmapUtil.decodePreview(imgFile).also(onPreview)     // 预览 Bitmap 交给 UI
+
+        /* --- 3. 拿原图尺寸,算缩放 --- */
+        val (actualW, actualH) = BitmapUtil.readImageSize(imgFile)
+        val backendW = itMapInfo.width!!.toFloat()
+        val backendH = itMapInfo.height!!.toFloat()
+        val ratioX = actualW / backendW
+        val ratioY = actualH / backendH
+        mapRatio = ratioX
+
+        /* --- 4. 生成 IsolationPoint 数据 --- */
+        mStationList.clear()
+        val cellPx = 50f
+        val offX = itMapInfo.x!!.toFloat()
+        val offY = itMapInfo.y!!.toFloat()
+        itMapInfo.pointList
+            ?.filter { it.x != null && it.y != null }
+            ?.forEach { pt ->
+                val gX = cellPx.times(pt.x!!.toFloat())           // 格子 → 全局像素
+                val gY = cellPx.times(pt.y!!.toFloat())
+                val lX = gX - offX                // 减去子图偏移
+                val lY = gY - offY
+                val fX = lX * ratioX              // 按实际 Bitmap 比例缩放
+                val fY = lY * ratioY
+
+                mStationList += CustomSwitchStationLayer.IsolationPoint(
+                    PointF(fX, fY),
+                    pt.entityName.orEmpty(),
+                    null,             // 小图标后面异步填充
+                    pt.entityId!!.toLong(),
+                    pt.pointSerialNumber,
+                    false
                 )
 
-                // 全部点都加载完后,设置给 layer 并绘制
-                if (mStationList.size == itMapInfo.pointList.count { it.x != null && it.y != null }) {
-                    if (stationLayer?.inDraw == true) {
-                        return@loadBitmapFromUrl
-                    }
-                    callback(mapBmp)
+                /* ---- 小图标异步加载,可选 ----*/
+                BitmapUtil.loadBitmapSmall(
+                    ctx      = context,
+                    url      = pt.pointIcon,
+                    reqW     = 64,
+                    reqH     = 64
+                ) { bmp ->
+                    mStationList.last().icon = bmp
+                    stationLayer?.refreshIfVisible(PointF(fX, fY))
                 }
             }
+
+        /* --- 5. 把计算好的列表塞给点位层 --- */
+        stationLayer?.submitPoints(mStationList)               // 你自己的 API
+
+        /* --- 6. 用 RegionTileLayer 替换旧底图 --- */
+        mapViewRef()?.let { mv ->
+            val tileLayer = RegionTileLayer(mv, imgFile)       // 512px tile, 内部 LruCache
+            mv.replaceLayer("bigMap", tileLayer)               // MapView 扩展函数
         }
     }
 

+ 147 - 106
app/src/main/java/com/grkj/iscs_mars/view/widget/CustomSwitchStationLayer.kt

@@ -16,13 +16,19 @@ import com.grkj.iscs_mars.modbus.ModBusController
 import com.grkj.iscs_mars.util.BitmapUtil
 import com.onlylemi.mapview.library.MapView
 import com.onlylemi.mapview.library.layer.MapBaseLayer
+import com.sik.sikcore.thread.ThreadUtils
 import kotlin.math.cos
 import kotlin.math.sin
 
+/**
+ * 自定义开关层级
+ */
 class CustomSwitchStationLayer @JvmOverloads constructor(
-    mapView: MapView?, private var pointList: List<IsolationPoint> = mutableListOf()
+    mapView: MapView?, private var stationList: MutableList<IsolationPoint> = mutableListOf()
 ) : MapBaseLayer(mapView) {
-    var inDraw = false
+    @Volatile
+    var inDraw: Boolean = false
+        private set
 
     // 呼吸灯周期(毫秒)
     private val breathePeriod = 1200f
@@ -40,46 +46,64 @@ class CustomSwitchStationLayer @JvmOverloads constructor(
             startAnimation()
         }
     }
-
-    private var listener: MarkIsClickListener? = null
-    private var radiusMark = 0f
-    private var isClickMark: Boolean = false
-    private var num: Int = -1
     private lateinit var paint: Paint
-    private var btnIndex: Int = -1
     private var currentZoom = 0f
     private var currentDegree = 0f
-    private var bgBitmap: Bitmap? = null
-    private var coverBitmap: Bitmap? = null
     private var ratio: Float = 1f
     private var switchSize: Float = 1f
+    private var textSize: Float = 1f
 
     init {
         initLayer()
     }
 
     private fun initLayer() {
-        radiusMark = setValue(6.0f)
         paint = Paint()
         paint.isAntiAlias = true
         paint.style = Paint.Style.FILL_AND_STROKE
     }
 
+    /**
+     * Presenter 计算好所有点后调用此方法;线程安全,可任何线程调用。
+     */
+    fun submitPoints(points: List<IsolationPoint>) {
+        synchronized(stationList) {
+            stationList.clear()
+            stationList.addAll(points)
+        }
+        mapView.postInvalidate()   // 触发重绘
+    }
+
+    /**
+     * 若给定点位在屏幕可视区域内,则执行一次重绘;否则忽略。
+     * 可在异步加载小图标完成后调用,避免整图层频繁刷新。
+     */
+    fun refreshIfVisible(point: PointF, margin: Float = 0f) {
+        // 如果当前仍在 draw() 里,直接返回,等那一帧完成即可
+        if (inDraw) return
+
+        val w = mapView.width
+        val h = mapView.height
+        if (w == 0 || h == 0) return
+
+        // 将点位的原图坐标转换为屏幕坐标
+        val pts = floatArrayOf(point.x, point.y)
+        // 假设 MapView 暴露 currentMatrix;如项目中名称不同请自行调整
+        mapView.currentMatrix.mapPoints(pts)
+
+        val x = pts[0]
+        val y = pts[1]
+        if (x + margin >= 0 && x - margin <= w &&
+            y + margin >= 0 && y - margin <= h
+        ) {
+            mapView.postInvalidate()
+        }
+    }
+
     fun setRatio(ratio: Float) {
         this.ratio = ratio
-        bgBitmap = BitmapUtil.getResizedBitmapFromDrawable(
-            mapView.context,
-            R.drawable.red_stroke_bg,
-            (50 * ratio).toInt(),
-            (78 * ratio).toInt()
-        )!!
-        coverBitmap = BitmapUtil.getResizedBitmapFromDrawable(
-            mapView.context,
-            R.drawable.map_item_cover_bg,
-            (50 * ratio).toInt(),
-            (78 * ratio).toInt()
-        )!!
         switchSize = setValue(4 * ratio)
+        textSize = switchSize
     }
 
     fun startAnimation() {
@@ -114,7 +138,7 @@ class CustomSwitchStationLayer @JvmOverloads constructor(
         // 把 mapView 本身的缩放/平移/旋转一次性 concat 到 Canvas
         canvas.concat(currentMatrix)
         val switchData = ModBusController.getSwitchData()
-        val tempPointList = pointList.toList()
+        val tempPointList = stationList.toList()
         tempPointList.forEach { point ->
             val switchStatus = switchData
                 .find { it.idx == point.pointSerialNumber?.toInt() }?.enabled
@@ -122,108 +146,125 @@ class CustomSwitchStationLayer @JvmOverloads constructor(
             val x = point.pos.x
             val y = point.pos.y
             // 先画背景(它会被 currentMatrix 自动缩放)
-            bgBitmap?.let {
-                canvas.drawBitmap(
-                    it, x, y, paint
-                )
-                paint.alpha = 255
-                if (switchStatus != null) {
-                    // 再画 icon
-                    if (switchStatus) {
-                        paint.color = ContextCompat.getColor(
-                            MyApplication.instance?.applicationContext!!,
-                            R.color.common_switch_enable
-                        )
-                        paint.alpha = alpha
-                        canvas.drawCircle(
-                            x + (it.width - switchSize) / 2 + switchSize / 2,
-                            y + (it.width - switchSize) / 2 + switchSize / 2,
-                            switchSize, paint
-                        )
-                        paint.alpha = 255
-                    } else {
-                        paint.color = ContextCompat.getColor(
-                            MyApplication.instance?.applicationContext!!,
-                            R.color.common_switch_disable
-                        )
-                        canvas.drawCircle(
-                            x + (it.width - switchSize) / 2 + switchSize / 2,
-                            y + (it.width - switchSize) / 2 + switchSize / 2,
-                            switchSize, paint
-                        )
-                    }
-//                    point.icon?.let { icon ->
-//                        canvas.drawBitmap(
-//                            icon,
-//                            x + (it.width - icon.width) / 2,
-//                            y + (it.width - icon.width) / 2,
-//                            paint
-//                        )
-//                    }
-                }
-                // 然后画文字
-                paint.style = Paint.Style.FILL
-                paint.strokeWidth = 1f
-                paint.color = Color.RED
-                paint.textSize = radiusMark * ratio  // 这里是「图内」的文字大小,后面会跟着缩放
-                val textW = paint.measureText(point.entityName)
-                canvas.drawText(
-                    point.entityName,
-                    x + (it.width - textW) / 2,
-                    y + (it.height - radiusMark / 2 - it.height / 10),
-                    paint
-                )
-
-                // 如果选中,再叠加一个标记
+            paint.alpha = 255
+            if (switchStatus != null) {
                 if (point.isSelected) {
-                    coverBitmap?.let {
-                        canvas.drawBitmap(
-                            it, x, y, paint
-                        )
-                    }
-                    val checkW = paint.measureText("√")
-                    paint.color = Color.WHITE
-                    canvas.drawText(
-                        "√",
-                        x + (it.width - checkW) / 2,
-                        y + (it.height / 2 + radiusMark / 2),
+                    paint.color = Color.RED
+                    paint.strokeWidth = 2f
+                    paint.style = Paint.Style.STROKE
+                    canvas.drawRect(
+                        x - switchSize / 2 - paint.strokeWidth,
+                        y - switchSize / 2 - paint.strokeWidth,
+                        x + switchSize * 1.5f + paint.strokeWidth,
+                        y + switchSize * 1.5f + paint.strokeWidth,
                         paint
                     )
                 }
+                paint.style = Paint.Style.FILL_AND_STROKE
+                // 再画 icon
+                if (switchStatus) {
+                    paint.color = ContextCompat.getColor(
+                        MyApplication.instance?.applicationContext!!,
+                        R.color.common_switch_enable
+                    )
+                    paint.alpha = alpha
+                    canvas.drawCircle(
+                        x + switchSize / 2,
+                        y + switchSize / 2,
+                        switchSize, paint
+                    )
+                    paint.alpha = 255
+                } else {
+                    paint.color = ContextCompat.getColor(
+                        MyApplication.instance?.applicationContext!!,
+                        R.color.common_switch_disable
+                    )
+                    canvas.drawCircle(
+                        x + switchSize / 2,
+                        y + switchSize / 2,
+                        switchSize, paint
+                    )
+                }
             }
+            // 然后画文字
+            paint.style = Paint.Style.FILL
+            paint.strokeWidth = 1f
+            paint.color = Color.WHITE
+            paint.textSize = textSize  // 这里是「图内」的文字大小,后面会跟着缩放
+            val textW = paint.measureText(point.pointSerialNumber ?: "")
+            // 计算文本宽度和偏移
+            val fontMetrics = paint.fontMetrics
+            val textHeight = fontMetrics.bottom - fontMetrics.top
+
+            // 计算绘制文本的起点(左下角)
+            val textX = x + (switchSize - textW) / 2
+            val textY = y + switchSize / 2 + (textHeight / 2 - fontMetrics.bottom)
+            canvas.drawText(
+                "${point.pointSerialNumber}",
+                textX,
+                textY,
+                paint
+            )
         }
 
         canvas.restore()
         inDraw = false
     }
 
-    private fun rotatePoint(
-        oriX: Float, oriY: Float, desX: Float, desY: Float, rotateDegrees: Float
-    ): Pair<Float, Float> {
-        // 将度数转换为弧度
-        val theta = Math.toRadians(rotateDegrees.toDouble())
-
-        // 计算旋转后的坐标
-        val newX = (oriX - desX) * cos(theta) - (oriY - desY) * sin(theta) + desX
-        val newY = (oriX - desX) * sin(theta) + ((oriY - desY) * cos(theta)) + desY
-
-        return Pair(newX.toFloat(), newY.toFloat())
+    /**
+     * 根据序号查找点位的「图内坐标」。
+     * @return 对应的 PointF(x,y),找不到返回 null。
+     */
+    fun getPointPosition(serial: String): PointF? {
+        return stationList
+            .find { it.pointSerialNumber == serial }
+            ?.pos
     }
 
-    fun setMarkIsClickListener(listener: MarkIsClickListener?) {
-        this.listener = listener
-    }
+    /**
+     * 选中一个点位:标记 isSelected 并定位到视图中心。
+     * @param serial      要选中的序号
+     * @param animated    是否平滑动画,false 则瞬间定位
+     * @param durationMs  动画时长(仅 animated = true 时生效)
+     * @return 是否成功找到了并处理
+     */
+    fun selectPoint(
+        serial: String,
+        animated: Boolean = true,
+        durationMs: Long = 300L
+    ): Boolean {
+        val point = stationList.find { it.pointSerialNumber == serial } ?: return false
+        point.isSelected = true
+        ThreadUtils.runOnMainDelayed(3000) {
+            point.isSelected = false
+            stationList.replaceAll {
+                it.copy(isSelected = false)
+            }
+            mapView?.postInvalidate()
+        }
+        // 1. 更新状态(如果你要改变外观,比如高亮,记得在 draw() 里用 isSelected)
+        stationList.replaceAll {
+            it.copy(isSelected = (it.pointSerialNumber == serial))
+        }
+        mapView?.postInvalidate()
 
-    interface MarkIsClickListener {
-        fun markIsClick(index: Int, btnIndex: Int)
+        // 2. 定位:调用 MapView 的 API
+        mapView?.let { mv ->
+            if (animated) {
+                mv.animateCenterOnPoint(point.pos.x, point.pos.y, durationMs)
+            } else {
+                mv.mapCenterWithPoint(point.pos.x, point.pos.y)
+            }
+        }
+        return true
     }
 
     data class IsolationPoint(
         val pos: PointF,
         val entityName: String,
-        val icon: Bitmap?,
+        var icon: Bitmap?,
         val entityId: Long,
         val pointSerialNumber: String?,
-        val isSelected: Boolean,
+        var isSelected: Boolean,
     )
 }

+ 23 - 0
app/src/main/java/com/grkj/iscs_mars/view/widget/RegionTileLayer.kt

@@ -0,0 +1,23 @@
+package com.grkj.iscs_mars.view.widget
+
+import android.graphics.Canvas
+import android.graphics.Matrix
+import android.view.MotionEvent
+import com.onlylemi.mapview.library.MapView
+import com.onlylemi.mapview.library.layer.MapBaseLayer
+import java.io.File
+
+class RegionTileLayer(val mapView: MapView?, val mapFile: File) : MapBaseLayer(mapView) {
+    override fun onTouch(event: MotionEvent) {
+
+    }
+
+    override fun draw(
+        canvas: Canvas,
+        currentMatrix: Matrix,
+        currentZoom: Float,
+        currentRotateDegrees: Float
+    ) {
+
+    }
+}

+ 50 - 1
app/src/main/java/com/onlylemi/mapview/library/MapView.java

@@ -11,6 +11,9 @@ import android.graphics.SurfaceTexture;
 import android.util.AttributeSet;
 import android.view.MotionEvent;
 import android.view.TextureView;
+import android.animation.ValueAnimator;
+import android.graphics.Matrix;
+import android.view.animation.AccelerateDecelerateInterpolator;
 
 import com.grkj.iscs_mars.util.log.LogUtil;
 import com.onlylemi.mapview.library.layer.MapBaseLayer;
@@ -33,7 +36,7 @@ public class MapView extends TextureView implements TextureView.SurfaceTextureLi
     private MapLayer mapLayer;
 
     private final Matrix saveMatrix = new Matrix();
-    private final Matrix currentMatrix = new Matrix();
+    public final Matrix currentMatrix = new Matrix();
     private float currentZoom = 1.0f;
     private float saveZoom = 1.0f;           // 默认初始保存zoom为1
     private float currentRotateDegrees = 0.0f;
@@ -316,4 +319,50 @@ public class MapView extends TextureView implements TextureView.SurfaceTextureLi
     public float getMapHeight() {
         return mapLayer != null ? mapLayer.getImage().getHeight() : 0f;
     }
+
+    /**
+     * 瞬间把地图上的 (x, y) 点移动到屏幕中心。
+     */
+    public void centerOnPoint(float x, float y) {
+        mapCenterWithPoint(x, y);
+    }
+
+    /**
+     * 瞬间把地图上的 (x, y) 点移动到屏幕中心,并切换到指定缩放级别。
+     * 注意:zoom 要在 minZoom~maxZoom 范围内。
+     */
+    public void centerAndZoom(float x, float y, float zoom) {
+        setCurrentZoom(zoom);
+        mapCenterWithPoint(x, y);
+    }
+
+    /**
+     * 平滑动画地把地图上的 (x, y) 点移动到屏幕中心。
+     *
+     * @param x        地图坐标 x
+     * @param y        地图坐标 y
+     * @param duration 动画时长(ms),建议 200~500
+     */
+    public void animateCenterOnPoint(final float x, final float y, long duration) {
+        // 1. 计算目标偏移
+        float[] pts = { x, y };
+        currentMatrix.mapPoints(pts);
+        final float targetDx = getWidth() / 2f - pts[0];
+        final float targetDy = getHeight() / 2f - pts[1];
+
+        // 2. 记录初始矩阵
+        final Matrix startMatrix = new Matrix(currentMatrix);
+
+        // 3. ValueAnimator 插值执行平移
+        ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f);
+        animator.setDuration(duration);
+        animator.setInterpolator(new AccelerateDecelerateInterpolator());
+        animator.addUpdateListener(animation -> {
+            float frac = (float) animation.getAnimatedValue();
+            currentMatrix.set(startMatrix);
+            currentMatrix.postTranslate(targetDx * frac, targetDy * frac);
+            refresh();
+        });
+        animator.start();
+    }
 }

+ 2 - 0
app/src/main/java/com/onlylemi/mapview/library/layer/MapBaseLayer.java

@@ -24,6 +24,8 @@ public abstract class MapBaseLayer {
     // layer is/not show
     public boolean isVisible = true;
 
+    public String tag;
+
     protected MapView mapView;
 
     public MapBaseLayer(MapView mapView) {

+ 10 - 1
app/src/main/res/layout/activity_switch_status.xml

@@ -62,7 +62,7 @@
 
             <LinearLayout
                 android:layout_width="match_parent"
-                android:layout_height="match_parent"
+                android:layout_height="wrap_content"
                 android:layout_marginTop="@dimen/common_spacing"
                 android:orientation="horizontal">
 
@@ -95,6 +95,15 @@
                     android:text="@string/switch_status"
                     android:textColor="@color/black"
                     android:textSize="@dimen/common_text_size" />
+                <TextView
+                    android:layout_width="0dp"
+                    android:layout_height="wrap_content"
+                    android:layout_weight="1"
+                    android:gravity="center"
+                    android:paddingVertical="@dimen/common_spacing_small"
+                    android:text="@string/operation"
+                    android:textColor="@color/black"
+                    android:textSize="@dimen/common_text_size" />
             </LinearLayout>
 
             <androidx.recyclerview.widget.RecyclerView

+ 1 - 0
app/src/main/res/values-en/strings.xml

@@ -396,4 +396,5 @@
     <string name="switch_open">Open</string>
     <string name="switch_close">Close</string>
     <string name="positioning">Positioning</string>
+    <string name="operation">Operation</string>
 </resources>

+ 1 - 0
app/src/main/res/values-zh/strings.xml

@@ -396,4 +396,5 @@
     <string name="switch_open">开启</string>
     <string name="switch_close">关闭</string>
     <string name="positioning">定位</string>
+    <string name="operation">操作</string>
 </resources>

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -396,4 +396,5 @@
     <string name="switch_open">开启</string>
     <string name="switch_close">关闭</string>
     <string name="positioning">定位</string>
+    <string name="operation">操作</string>
 </resources>