518 lines
17 KiB
C#
518 lines
17 KiB
C#
using System;
|
||
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using System.Runtime.InteropServices;
|
||
using Kill.UI.Components;
|
||
using UnityEngine;
|
||
using UnityEngine.UI;
|
||
|
||
namespace Kill.UI.Pages
|
||
{
|
||
/// <summary>
|
||
/// 二维码扫描器 - 调用摄像头扫描二维码
|
||
/// Android: 使用 WebCamTexture + ZXing 解码
|
||
/// iOS: 使用原生 AVCaptureSession(连续对焦 + 原生 QR 检测)
|
||
/// </summary>
|
||
public class ScanQRcode : MonoBehaviour
|
||
{
|
||
[Serializable]
|
||
public enum ScanType
|
||
{
|
||
ConnectDevice, // 连接设备
|
||
Other // 其他用途
|
||
}
|
||
|
||
[Header("UI组件")]
|
||
public RawImage cameraPreview; // 摄像头预览
|
||
|
||
[Header("扫描设置")]
|
||
float scanInterval = 0.2f; // 扫描间隔(秒)
|
||
public ScanType scanType = ScanType.ConnectDevice; // 扫描类型
|
||
|
||
[Header("自动对焦")]
|
||
[Tooltip("启用对焦")]
|
||
public bool enableAutoFocus = true;
|
||
|
||
#if UNITY_IOS && !UNITY_EDITOR
|
||
[DllImport("__Internal")]
|
||
private static extern void _StartNativeCamera(string gameObjectName);
|
||
|
||
[DllImport("__Internal")]
|
||
private static extern void _StopNativeCamera();
|
||
#endif
|
||
|
||
// 摄像头相关
|
||
private WebCamTexture webCamTexture;
|
||
private WebCamDevice currentDevice;
|
||
private bool isScanning = false;
|
||
private bool isProcessing = false;
|
||
|
||
// 扫描结果回调
|
||
public event Action<string> OnQRCodeScanned;
|
||
|
||
// 预览纹理(所有平台共用清理逻辑)
|
||
private Texture2D previewTexture;
|
||
|
||
void OnEnable()
|
||
{
|
||
StartScan();
|
||
}
|
||
|
||
void OnDisable()
|
||
{
|
||
StopScan();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 开始扫描
|
||
/// </summary>
|
||
public void StartScan()
|
||
{
|
||
if (isScanning) return;
|
||
|
||
#if UNITY_IOS && !UNITY_EDITOR
|
||
// iOS: 使用原生 AVCaptureSession(连续对焦 + 原生 QR 检测)
|
||
if (cameraPreview != null)
|
||
cameraPreview.gameObject.SetActive(false);
|
||
_StartNativeCamera(gameObject.name);
|
||
isScanning = true;
|
||
#else
|
||
// Android/其他: 使用 WebCamTexture + ZXing
|
||
StartCoroutine(InitializeCamera());
|
||
#endif
|
||
}
|
||
|
||
/// <summary>
|
||
/// 停止扫描
|
||
/// </summary>
|
||
public void StopScan()
|
||
{
|
||
isScanning = false;
|
||
StopAllCoroutines();
|
||
|
||
#if UNITY_IOS && !UNITY_EDITOR
|
||
_StopNativeCamera();
|
||
if (cameraPreview != null)
|
||
cameraPreview.gameObject.SetActive(true);
|
||
#endif
|
||
|
||
if (webCamTexture != null && webCamTexture.isPlaying)
|
||
{
|
||
webCamTexture.Stop();
|
||
webCamTexture = null;
|
||
}
|
||
|
||
if (previewTexture != null)
|
||
{
|
||
Destroy(previewTexture);
|
||
previewTexture = null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 关闭扫码界面
|
||
/// </summary>
|
||
public void Close()
|
||
{
|
||
StopScan();
|
||
gameObject.SetActive(false);
|
||
}
|
||
|
||
// ──── iOS 原生回调 ────
|
||
|
||
/// <summary>
|
||
/// 由 iOS 原生插件调用(通过 UnitySendMessage)
|
||
/// </summary>
|
||
public void OnNativeScanResult(string result)
|
||
{
|
||
if (string.IsNullOrEmpty(result))
|
||
return;
|
||
|
||
if (result == "CAMERA_READY")
|
||
{
|
||
Debug.Log("[ScanQRcode] 原生摄像头已就绪");
|
||
return;
|
||
}
|
||
|
||
if (result.StartsWith("ERROR:"))
|
||
{
|
||
Debug.LogError($"[ScanQRcode] 原生摄像头错误: {result}");
|
||
ToastUI.Show("100086");
|
||
return;
|
||
}
|
||
|
||
Debug.Log($"[ScanQRcode] 原生扫描结果: {result}");
|
||
ProcessScanResult(result);
|
||
}
|
||
|
||
// ──── Android / 编辑器的 WebCamTexture 方案 ────
|
||
#if !UNITY_IOS || UNITY_EDITOR
|
||
|
||
/// <summary>
|
||
/// 初始化摄像头(WebCamTexture 方案)
|
||
/// </summary>
|
||
private IEnumerator InitializeCamera()
|
||
{
|
||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||
if (!UnityEngine.Android.Permission.HasUserAuthorizedPermission(UnityEngine.Android.Permission.Camera))
|
||
{
|
||
UnityEngine.Android.Permission.RequestUserPermission(UnityEngine.Android.Permission.Camera);
|
||
yield return new WaitUntil(() => UnityEngine.Android.Permission.HasUserAuthorizedPermission(UnityEngine.Android.Permission.Camera));
|
||
}
|
||
|
||
if (!UnityEngine.Android.Permission.HasUserAuthorizedPermission(UnityEngine.Android.Permission.Camera))
|
||
{
|
||
ToastUI.Show("100085");
|
||
yield break;
|
||
}
|
||
#else
|
||
yield return Application.RequestUserAuthorization(UserAuthorization.WebCam);
|
||
|
||
if (!Application.HasUserAuthorization(UserAuthorization.WebCam))
|
||
{
|
||
ToastUI.Show("100085");
|
||
yield break;
|
||
}
|
||
#endif
|
||
|
||
WebCamDevice[] devices = WebCamTexture.devices;
|
||
if (devices.Length == 0)
|
||
{
|
||
ToastUI.Show("100086");
|
||
yield break;
|
||
}
|
||
|
||
// 优先后置主摄
|
||
string deviceName = devices[0].name;
|
||
currentDevice = devices[0];
|
||
for (int i = 0; i < devices.Length; i++)
|
||
{
|
||
if (!devices[i].isFrontFacing)
|
||
{
|
||
string lowerName = devices[i].name.ToLower();
|
||
if (!lowerName.Contains("telephoto") && !lowerName.Contains("ultra wide"))
|
||
{
|
||
deviceName = devices[i].name;
|
||
currentDevice = devices[i];
|
||
break;
|
||
}
|
||
if (deviceName == devices[0].name)
|
||
{
|
||
deviceName = devices[i].name;
|
||
currentDevice = devices[i];
|
||
}
|
||
}
|
||
}
|
||
Debug.Log($"[ScanQRcode] 选中摄像头: {deviceName}");
|
||
|
||
webCamTexture = new WebCamTexture(deviceName, 2160, 30);
|
||
webCamTexture.Play();
|
||
|
||
yield return new WaitUntil(() => webCamTexture.width > 100);
|
||
|
||
AdjustPreviewAspect();
|
||
|
||
isScanning = true;
|
||
StartCoroutine(PreviewUpdateCoroutine());
|
||
StartCoroutine(ScanCoroutine());
|
||
|
||
if (enableAutoFocus)
|
||
{
|
||
StartCoroutine(AutoFocusCoroutine());
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 自动对焦维护协程
|
||
/// </summary>
|
||
private IEnumerator AutoFocusCoroutine()
|
||
{
|
||
while (isScanning)
|
||
{
|
||
if (webCamTexture != null && webCamTexture.isPlaying)
|
||
{
|
||
webCamTexture.autoFocusPoint = new Vector2(0.5f, 0.5f);
|
||
}
|
||
yield return new WaitForSeconds(1f);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 调整预览画面比例
|
||
/// </summary>
|
||
private void AdjustPreviewAspect()
|
||
{
|
||
if (webCamTexture == null || cameraPreview == null) return;
|
||
|
||
int cameraWidth = webCamTexture.width;
|
||
int cameraHeight = webCamTexture.height;
|
||
|
||
RectTransform previewRect = cameraPreview.GetComponent<RectTransform>();
|
||
int previewWidth = (int)previewRect.rect.width;
|
||
int previewHeight = (int)previewRect.rect.height;
|
||
|
||
if (previewTexture == null || previewTexture.width != previewWidth || previewTexture.height != previewHeight)
|
||
{
|
||
if (previewTexture != null)
|
||
Destroy(previewTexture);
|
||
previewTexture = new Texture2D(previewWidth, previewHeight, TextureFormat.RGB24, false);
|
||
}
|
||
|
||
CutAndRotateCameraFrame(cameraWidth, cameraHeight, previewWidth, previewHeight);
|
||
|
||
cameraPreview.texture = previewTexture;
|
||
cameraPreview.uvRect = new Rect(0, 0, 1, 1);
|
||
previewRect.localEulerAngles = Vector3.zero;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从相机画面裁切并旋转,生成预览画面
|
||
/// </summary>
|
||
private void CutAndRotateCameraFrame(int camW, int camH, int targetW, int targetH)
|
||
{
|
||
int cropY = (camW - targetH) / 2;
|
||
Color32[] cameraPixels = webCamTexture.GetPixels32();
|
||
Color32[] targetPixels = new Color32[targetW * targetH];
|
||
|
||
for (int y = 0; y < targetH; y++)
|
||
{
|
||
for (int x = 0; x < targetW; x++)
|
||
{
|
||
int srcX = cropY + (targetH - 1 - y);
|
||
int srcY = x;
|
||
|
||
if (srcX >= 0 && srcX < camW && srcY >= 0 && srcY < camH)
|
||
{
|
||
int srcIndex = srcY * camW + srcX;
|
||
int dstIndex = y * targetW + x;
|
||
targetPixels[dstIndex] = cameraPixels[srcIndex];
|
||
}
|
||
}
|
||
}
|
||
|
||
previewTexture.SetPixels32(targetPixels);
|
||
previewTexture.Apply();
|
||
}
|
||
|
||
[SerializeField] private float previewUpdateInterval = 0.03f;
|
||
|
||
private IEnumerator PreviewUpdateCoroutine()
|
||
{
|
||
while (isScanning && webCamTexture != null && webCamTexture.isPlaying)
|
||
{
|
||
if (previewTexture != null)
|
||
{
|
||
CutAndRotateCameraFrame(webCamTexture.width, webCamTexture.height, previewTexture.width, previewTexture.height);
|
||
}
|
||
yield return new WaitForSeconds(previewUpdateInterval);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 扫描协程(ZXing 解码)
|
||
/// </summary>
|
||
private IEnumerator ScanCoroutine()
|
||
{
|
||
yield return null;
|
||
|
||
while (isScanning && webCamTexture != null && webCamTexture.isPlaying)
|
||
{
|
||
if (isProcessing)
|
||
{
|
||
yield return null;
|
||
continue;
|
||
}
|
||
|
||
isProcessing = true;
|
||
|
||
if (previewTexture != null)
|
||
{
|
||
Color32[] pixels = previewTexture.GetPixels32();
|
||
int texWidth = previewTexture.width;
|
||
int texHeight = previewTexture.height;
|
||
byte[] rgbBytes = new byte[pixels.Length * 3];
|
||
for (int i = 0; i < pixels.Length; i++)
|
||
{
|
||
rgbBytes[i * 3] = pixels[i].r;
|
||
rgbBytes[i * 3 + 1] = pixels[i].g;
|
||
rgbBytes[i * 3 + 2] = pixels[i].b;
|
||
}
|
||
|
||
string result = null;
|
||
bool scanComplete = false;
|
||
|
||
System.Threading.ThreadPool.QueueUserWorkItem(_ =>
|
||
{
|
||
try
|
||
{
|
||
result = DecodeQRCodeFromBytes(rgbBytes, texWidth, texHeight);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogError($"[ScanQRcode] 解码异常: {e.Message}");
|
||
}
|
||
finally
|
||
{
|
||
scanComplete = true;
|
||
}
|
||
});
|
||
|
||
yield return new WaitUntil(() => scanComplete);
|
||
|
||
if (!string.IsNullOrEmpty(result))
|
||
{
|
||
ProcessScanResult(result);
|
||
}
|
||
}
|
||
|
||
isProcessing = false;
|
||
yield return new WaitForSeconds(scanInterval);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从原始字节数据解析二维码(后台线程安全)
|
||
/// </summary>
|
||
private string DecodeQRCodeFromBytes(byte[] rgbBytes, int width, int height)
|
||
{
|
||
try
|
||
{
|
||
var luminanceSource = new ZXing.RGBLuminanceSource(rgbBytes, width, height);
|
||
string result = TryDecodeWithBinarizer(luminanceSource);
|
||
if (!string.IsNullOrEmpty(result))
|
||
return result;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"二维码解析失败: {ex.Message}");
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 尝试使用不同的二值化算法解码
|
||
/// </summary>
|
||
private string TryDecodeWithBinarizer(ZXing.LuminanceSource luminanceSource)
|
||
{
|
||
var hints = new Dictionary<ZXing.DecodeHintType, object>
|
||
{
|
||
{ ZXing.DecodeHintType.TRY_HARDER, true },
|
||
{ ZXing.DecodeHintType.POSSIBLE_FORMATS, new List<ZXing.BarcodeFormat> { ZXing.BarcodeFormat.QR_CODE } }
|
||
};
|
||
|
||
var reader = new ZXing.QrCode.QRCodeReader();
|
||
|
||
try
|
||
{
|
||
var binarizer = new ZXing.Common.HybridBinarizer(luminanceSource);
|
||
var binaryBitmap = new ZXing.BinaryBitmap(binarizer);
|
||
var result = reader.decode(binaryBitmap, hints);
|
||
if (result != null)
|
||
return result.Text;
|
||
}
|
||
catch { }
|
||
|
||
try
|
||
{
|
||
var binarizer = new ZXing.Common.GlobalHistogramBinarizer(luminanceSource);
|
||
var binaryBitmap = new ZXing.BinaryBitmap(binarizer);
|
||
var result = reader.decode(binaryBitmap, hints);
|
||
if (result != null)
|
||
return result.Text;
|
||
}
|
||
catch { }
|
||
|
||
return null;
|
||
}
|
||
#endif // !UNITY_IOS || UNITY_EDITOR
|
||
|
||
// ──── 公共处理方法 ────
|
||
|
||
/// <summary>
|
||
/// 处理扫描结果
|
||
/// </summary>
|
||
private void ProcessScanResult(string result)
|
||
{
|
||
Debug.Log($"[ScanQRcode] 扫描结果: {result}");
|
||
|
||
if (scanType == ScanType.ConnectDevice)
|
||
{
|
||
string macAddress = ExtractMacAddress(result);
|
||
if (!string.IsNullOrEmpty(macAddress))
|
||
{
|
||
OnQRCodeScanned?.Invoke(macAddress);
|
||
OnQRCodeScanned = null;
|
||
}
|
||
else
|
||
{
|
||
ToastUI.Show("100084");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (!string.IsNullOrEmpty(result))
|
||
{
|
||
OnQRCodeScanned?.Invoke(result);
|
||
}
|
||
else
|
||
{
|
||
ToastUI.Show("100084");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从字符串中提取 MAC 地址
|
||
/// </summary>
|
||
private string ExtractMacAddress(string input)
|
||
{
|
||
if (string.IsNullOrEmpty(input))
|
||
return null;
|
||
|
||
var macKeyValueRegex = new System.Text.RegularExpressions.Regex(
|
||
@"MAC:([0-9A-Fa-f]{2}:?[0-9A-Fa-f]{2}:?[0-9A-Fa-f]{2}:?[0-9A-Fa-f]{2}:?[0-9A-Fa-f]{2}:?[0-9A-Fa-f]{2})");
|
||
|
||
var kvMatch = macKeyValueRegex.Match(input);
|
||
if (kvMatch.Success)
|
||
return FormatMacAddress(kvMatch.Groups[1].Value);
|
||
|
||
var macColonRegex = new System.Text.RegularExpressions.Regex(
|
||
@"[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}");
|
||
var colonMatch = macColonRegex.Match(input);
|
||
if (colonMatch.Success)
|
||
return FormatMacAddress(colonMatch.Value);
|
||
|
||
var macNoColonRegex = new System.Text.RegularExpressions.Regex(@"[0-9A-Fa-f]{12}");
|
||
var noColonMatch = macNoColonRegex.Match(input);
|
||
if (noColonMatch.Success)
|
||
return FormatMacAddress(noColonMatch.Value);
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 格式化 MAC 地址为 XX:XX:XX:XX:XX:XX
|
||
/// </summary>
|
||
private string FormatMacAddress(string mac)
|
||
{
|
||
if (string.IsNullOrEmpty(mac))
|
||
return null;
|
||
|
||
string cleanMac = mac.Replace(":", "").Replace("-", "").ToUpper();
|
||
if (cleanMac.Length != 12)
|
||
return null;
|
||
|
||
return string.Format("{0}:{1}:{2}:{3}:{4}:{5}",
|
||
cleanMac.Substring(0, 2), cleanMac.Substring(2, 2),
|
||
cleanMac.Substring(4, 2), cleanMac.Substring(6, 2),
|
||
cleanMac.Substring(8, 2), cleanMac.Substring(10, 2));
|
||
}
|
||
|
||
void OnDestroy()
|
||
{
|
||
StopScan();
|
||
gameObject.SetActive(false);
|
||
}
|
||
}
|
||
}
|