鸿蒙WiFi小车手机控制App开发全指南:从零构建Android遥控终端

去年夏天,我在工作室里捣鼓一台基于Hi3861的鸿蒙系统WiFi小车时,突然意识到一个问题:每次测试都需要连着笔记本电脑,用C#编写的控制端程序发送指令。这种开发体验实在太不"移动"了!于是我开始探索如何用手机App替代电脑端控制,最终完成了一个简洁高效的Android控制应用。本文将分享这个过程中的关键技术和踩坑经验,帮助开发者快速构建自己的移动端控制方案。

1. 开发环境与项目初始化

在开始编码之前,我们需要确保开发环境配置正确。Android Studio是当前最主流的Android应用开发IDE,建议使用最新稳定版本(本文基于Android Studio Giraffe编写)。创建一个新项目时,选择"Empty Activity"模板即可,因为我们不需要复杂的界面元素。

关键依赖配置 :在模块级build.gradle文件中添加以下依赖项:

dependencies {
    implementation 'com.squareup.okhttp3:okhttp:4.9.3' // 网络通信库
    implementation 'com.google.code.gson:gson:2.8.9'   // JSON处理库
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1' // 协程支持
}

提示:使用OkHttp而不是原生HttpURLConnection,因为它提供了更简洁的API和更好的UDP支持。

项目结构建议如下:

/app
  ├── src/main
  │   ├── java/com/example/wificarcontroller
  │   │   ├── CarController.kt    # 小车控制逻辑
  │   │   ├── UdpManager.kt       # UDP通信管理
  │   │   └── MainActivity.kt     # 主界面
  │   └── res
  │       ├── layout/activity_main.xml
  │       └── values/strings.xml

2. 控制界面设计与实现

好的控制界面应该直观且易于操作。我采用了游戏手柄式的布局,将方向控制放在中央,功能按钮环绕四周。这种设计符合大多数用户的直觉认知。

在activity_main.xml中定义如下布局结构:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageButton
        android:id="@+id/btnForward"
        android:layout_centerHorizontal="true"
        android:src="@drawable/ic_up_arrow"/>
    
    <LinearLayout
        android:layout_below="@id/btnForward"
        android:orientation="horizontal">
        <ImageButton android:id="@+id/btnLeft" .../>
        <ImageButton android:id="@+id/btnStop" .../>
        <ImageButton android:id="@+id/btnRight" .../>
    </LinearLayout>

    <ImageButton
        android:id="@+id/btnBackward"
        android:layout_below="@id/btnLeftRightContainer"
        .../>
</RelativeLayout>

交互优化技巧

  • 使用 android:background="?attr/selectableItemBackgroundBorderless" 让按钮有点击反馈
  • 为按钮添加长按事件监听,实现持续控制功能
  • 采用Material Design的触觉反馈API,增强操作体验
btnForward.setOnLongClickListener {
    vibrate(50) // 50ms的震动反馈
    sendCommand("FORWARD")
    true
}

3. UDP通信核心实现

与鸿蒙小车的通信采用UDP协议,这是物联网设备控制的常见选择。我们需要处理三个关键问题:消息格式、连接管理和异常处理。

3.1 消息格式设计

与小车端的通信采用JSON格式,保持与PC端控制程序的兼容性。基本命令结构如下:

{
  "command": "FORWARD",
  "duration": 1000,
  "speed": 75
}

在Kotlin中,我们定义对应的数据类:

data class CarCommand(
    val command: String, 
    val duration: Int? = null,
    val speed: Int? = null
) {
    fun toJson(): String = Gson().toJson(this)
}

3.2 UDP管理器实现

创建一个UdpManager单例类处理所有网络操作:

object UdpManager {
    private const val PORT = 50001
    private lateinit var socket: DatagramSocket
    
    fun initialize() {
        socket = DatagramSocket().apply {
            broadcast = true
        }
    }
    
    fun sendCommand(ip: String, command: CarCommand) {
        try {
            val bytes = command.toJson().toByteArray()
            val address = InetAddress.getByName(ip)
            val packet = DatagramPacket(bytes, bytes.size, address, PORT)
            socket.send(packet)
        } catch (e: Exception) {
            Log.e("UDP", "Send failed", e)
        }
    }
    
    fun discoverCar(): String? {
        // 广播发现逻辑...
    }
}

注意:Android 9及以上版本对UDP广播有限制,需要在AndroidManifest.xml中添加 <uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> 权限。

4. 小车IP自动发现与手动配置

可靠的IP发现机制是提升用户体验的关键。我们实现两种方式:广播发现和手动配置。

4.1 广播发现实现

fun discoverCar(): String? {
    val discoverPacket = DatagramPacket(
        "DISCOVER".toByteArray(),
        "DISCOVER".length,
        InetAddress.getByName("255.255.255.255"),
        PORT
    )
    
    socket.send(discoverPacket)
    
    val buffer = ByteArray(1024)
    val response = DatagramPacket(buffer, buffer.size)
    
    socket.soTimeout = 2000 // 2秒超时
    try {
        socket.receive(response)
        return response.address.hostAddress
    } catch (e: SocketTimeoutException) {
        return null
    }
}

4.2 IP配置管理

对于需要手动配置的情况,我们使用SharedPreferences保存历史IP:

class IpManager(private val context: Context) {
    private val prefs = context.getSharedPreferences("car_ip", Context.MODE_PRIVATE)
    
    fun saveIp(ip: String) {
        prefs.edit().putString("last_ip", ip).apply()
    }
    
    fun getLastIp(): String? = prefs.getString("last_ip", null)
}

在界面中添加IP输入对话框:

fun showIpDialog() {
    MaterialAlertDialogBuilder(this)
        .setTitle("输入小车IP")
        .setView(EditText(this).apply {
            setText(ipManager.getLastIp())
        })
        .setPositiveButton("连接") { _, _ -> 
            val ip = (dialog as EditText).text.toString()
            ipManager.saveIp(ip)
            connectToCar(ip)
        }
        .show()
}

5. 控制逻辑优化与性能调优

基础功能实现后,我们需要关注控制延迟和用户体验优化。

5.1 命令队列系统

为避免快速连续点击导致的命令丢失,实现一个简单的命令队列:

class CommandQueue {
    private val scope = CoroutineScope(Dispatchers.IO)
    private val channel = Channel<CarCommand>(capacity = 10)
    
    init {
        scope.launch {
            for (cmd in channel) {
                udpManager.sendCommand(currentIp, cmd)
                delay(50) // 最小命令间隔
            }
        }
    }
    
    fun enqueue(command: CarCommand) {
        scope.launch { channel.send(command) }
    }
}

5.2 运动控制算法

为小车实现平滑的加速/减速曲线:

fun calculateSpeedProfile(
    currentSpeed: Int,
    targetSpeed: Int,
    steps: Int = 5
): List<Int> {
    val diff = targetSpeed - currentSpeed
    return (1..steps).map { currentSpeed + (diff * it/steps) }
}

应用在控制逻辑中:

fun sendGradualCommand(command: String, targetSpeed: Int) {
    val speeds = calculateSpeedProfile(lastSpeed, targetSpeed)
    speeds.forEach { speed ->
        commandQueue.enqueue(CarCommand(command, speed = speed))
        delay(20)
    }
}

6. 高级功能扩展

基础控制稳定后,可以考虑添加以下增强功能:

6.1 传感器数据显示

如果小车配备了传感器,可以在App中显示实时数据:

// 小车端需要发送传感器数据
{
  "type": "SENSOR",
  "distance": 25.4,
  "speed": 30
}

// App端解析显示
fun handleSensorData(json: String) {
    val type = JsonParser.parseString(json).asJsonObject["type"].asString
    if (type == "SENSOR") {
        val data = Gson().fromJson(json, SensorData::class.java)
        runOnUiThread {
            textDistance.text = "${data.distance} cm"
            // 更新其他UI...
        }
    }
}

6.2 轨迹记录与回放

实现简单的运动轨迹记录:

class PathRecorder {
    private val path = mutableListOf<CarCommand>()
    
    fun startRecording() { path.clear() }
    
    fun recordCommand(cmd: CarCommand) { path.add(cmd) }
    
    fun replay() {
        path.forEach { cmd ->
            udpManager.sendCommand(currentIp, cmd)
            delay(cmd.duration ?: 100)
        }
    }
}

6.3 语音控制集成

利用Android的语音识别API添加语音控制:

private fun setupVoiceControl() {
    val recognizer = SpeechRecognizer.createSpeechRecognizer(this).apply {
        setRecognitionListener(object : RecognitionListener {
            override fun onResults(results: Bundle) {
                val matches = results.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
                matches?.firstOrNull()?.let { processVoiceCommand(it) }
            }
            // 其他回调方法...
        })
    }
    
    btnVoice.setOnClickListener {
        recognizer.startListening(Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
            putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, "en-US")
        })
    }
}

private fun processVoiceCommand(command: String) {
    when {
        command.contains("前进") -> sendCommand("FORWARD")
        command.contains("后退") -> sendCommand("BACKWARD")
        // 其他命令...
    }
}

7. 调试与问题排查

开发过程中难免遇到各种连接和控制问题,以下是我总结的常见问题及解决方法:

连接不稳定问题

  • 检查手机和小车是否在同一WiFi网络
  • 确认路由器没有开启客户端隔离功能
  • 测试网络延迟: ping <小车IP>

控制响应延迟高

  • 减少JSON数据体积,去掉不必要的字段
  • 适当增加UDP发送缓冲区大小
  • 在路由器设置中为小车设备分配静态IP

Android权限问题排查清单

  1. 确认AndroidManifest.xml已声明所有必要权限
  2. 检查Android 6.0+运行时权限是否已获取
  3. 在开发者选项中开启"无线调试日志"
  4. 使用Android Studio的Profiler工具监控网络活动

一个实用的调试技巧是在App中添加日志查看界面:

class DebugConsoleActivity : AppCompatActivity() {
    private val logEntries = mutableListOf<String>()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_debug)
        
        val logView = findViewById<TextView>(R.id.logView)
        val scrollView = findViewById<ScrollView>(R.id.scrollView)
        
        GlobalScope.launch(Dispatchers.Main) {
            while (true) {
                logView.text = logEntries.joinToString("\n")
                scrollView.fullScroll(View.FOCUS_DOWN)
                delay(500)
            }
        }
    }
    
    companion object {
        fun log(message: String) {
            logEntries.add("${SimpleDateFormat("HH:mm:ss").format(Date())}: $message")
            if (logEntries.size > 100) logEntries.removeAt(0)
        }
    }
}

在需要的地方调用 DebugConsoleActivity.log("发送前进命令") 即可记录调试信息。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐