繼上一篇《新手上手:Rokid移動端+眼鏡端最小實踐》之後,本文將帶你實現一個完整的天氣應用,充分利用Rokid眼鏡的特性:自定義界面顯示天氣信息,TTS語音播報天氣摘要,讓天氣信息在眼鏡端呈現得更直觀、更智能。 如果您有任何疑問、對文章寫的不滿意、發現錯誤或者有更好的方法,如果你想支持下一期請務必點贊~,歡迎在評論、私信或郵件中提出,非常感謝您的支持。🙏 那麼你將獲得 完整天氣應用:移動端獲取天氣 → 眼鏡端自定義界面顯示 → TTS語音播報 可直接複製的 Kotlin 代碼片段(天氣API調用、自定義界面JSON生成、TTS播報) 眼鏡特性深度應用:Custom View、全局TTS、界面更新 高頻踩坑與排錯清單
一、總體流程 天氣應用的完整流程如下: 圖片: https://uploader.shimo.im/f/CV8MaL2kJ7mOk1QD.png!thumbnail?source=all&accessToken=eyJhbGciOiJIUzI1NiIsImtpZCI6ImRlZmF1bHQiLCJ0eXAiOiJKV1QifQ.eyJleHAiOjE3NjM5ODM2NTAsImZpbGVHVUlEIjoiV3IzRHBwYVFvSkZldnAzSiIsImlhdCI6MTc2Mzk4MzM1MCwiaXNzIjoidXBsb2FkZXJfYWNjZXNzX3Jlc291cmNlIiwicGFhIjoiYWxsOmFsbDoiLCJ1c2VySWQiOjYxOTg3NjQ2fQ.cIN5qOZiJw8mxO9RPQqJpBHTEfJnR_Ehr_ykQ_Sgz1U 從移動端獲取天氣到眼鏡端顯示的完整路徑: 天氣數據獲取:移動端調用高德天氣API → 解析JSON響應 → 封裝天氣數據模型 自定義界面顯示:生成自定義界面JSON → 通過openCustomView()在眼鏡端打開天氣界面 → 實時顯示天氣信息 TTS語音播報:生成天氣摘要文本 → 通過sendGlobalTtsContent()在眼鏡端播報天氣 界面動態更新:使用updateCustomView()更新天氣界面,無需重新打開 雙向交互(可選):眼鏡端可發送刷新請求 → 移動端接收並刷新天氣數據 關鍵特性: 自定義界面(Custom View):在眼鏡端顯示結構化的天氣卡片 TTS語音播報:無需查看界面即可獲取天氣信息 界面更新:支持動態更新天氣數據,提升用户體驗 雙向通信:眼鏡端可主動請求移動端刷新數據 交互流程圖: 圖片: https://uploader.shimo.im/f/uySyHaGKhorfhssI.png!thumbnail?source=all&accessToken=eyJhbGciOiJIUzI1NiIsImtpZCI6ImRlZmF1bHQiLCJ0eXAiOiJKV1QifQ.eyJleHAiOjE3NjM5ODM2NTAsImZpbGVHVUlEIjoiV3IzRHBwYVFvSkZldnAzSiIsImlhdCI6MTc2Mzk4MzM1MCwiaXNzIjoidXBsb2FkZXJfYWNjZXNzX3Jlc291cmNlIiwicGFhIjoiYWxsOmFsbDoiLCJ1c2VySWQiOjYxOTg3NjQ2fQ.cIN5qOZiJw8mxO9RPQqJpBHTEfJnR_Ehr_ykQ_Sgz1U
二、技術架構 2.1 核心組件 WeatherModel.kt:天氣數據模型(基於高德API響應格式) WeatherApiHelper.kt:天氣API調用封裝(OkHttp + Gson) WeatherViewHelper.kt:自定義界面JSON生成工具 WeatherActivity.kt:移動端主Activity(整合所有功能) 2.2 數據流程 圖片: https://uploader.shimo.im/f/FzL627gzHTyger1Q.png!thumbnail?source=all&accessToken=eyJhbGciOiJIUzI1NiIsImtpZCI6ImRlZmF1bHQiLCJ0eXAiOiJKV1QifQ.eyJleHAiOjE3NjM5ODM2NTAsImZpbGVHVUlEIjoiV3IzRHBwYVFvSkZldnAzSiIsImlhdCI6MTc2Mzk4MzM1MCwiaXNzIjoidXBsb2FkZXJfYWNjZXNzX3Jlc291cmNlIiwicGFhIjoiYWxsOmFsbDoiLCJ1c2VySWQiOjYxOTg3NjQ2fQ.cIN5qOZiJw8mxO9RPQqJpBHTEfJnR_Ehr_ykQ_Sgz1U
三、移動端實現 3.1 天氣數據模型 基於高德天氣API的響應格式,定義Kotlin數據類:
import com.google.gson.annotations.SerializedName /**
- 高德天氣API響應數據模型 / data class WeatherApiResponse( /*
- 返回狀態 值為0或1 1:成功;0:失敗 / @SerializedName("status") val status: String? = null, /*
- 返回結果總數目 / @SerializedName("count") val count: String? = null, /*
- 返回的狀態信息 / @SerializedName("info") val info: String? = null, /*
- 返回狀態説明,10000代表正確 / @SerializedName("infocode") val infocode: String? = null, /*
- 實況天氣數據信息 / @SerializedName("lives") val lives: List<Live>? = null, /*
- 預報天氣數據信息 / @SerializedName("forecasts") val forecasts: List<Forecast>? = null ) /*
- 實況天氣數據信息 / data class Live( /*
- 省份名 / @SerializedName("province") val province: String? = null, /*
- 城市名 / @SerializedName("city") val city: String? = null, /*
- 區域編碼 / @SerializedName("adcode") val adcode: String? = null, /*
- 天氣現象(漢字描述) / @SerializedName("weather") val weather: String? = null, /*
- 實時氣温,單位:攝氏度 / @SerializedName("temperature") val temperature: String? = null, /*
- 風向描述 / @SerializedName("winddirection") val winddirection: String? = null, /*
- 風力級別,單位:級 / @SerializedName("windpower") val windpower: String? = null, /*
- 空氣濕度 / @SerializedName("humidity") val humidity: String? = null, /*
- 數據發佈的時間 / @SerializedName("reporttime") val reporttime: String? = null ) /*
- 預報天氣信息數據 / data class Forecast( /*
- 城市編碼 / @SerializedName("adcode") val adcode: String? = null, /*
- 省份名稱 / @SerializedName("province") val province: String? = null, /*
- 城市名稱 / @SerializedName("city") val city: String? = null, /*
- 預報發佈時間 / @SerializedName("reporttime") val reporttime: String? = null, /*
- 預報數據list結構,元素cast,按順序為當天、第二天、第三天的預報數據 / @SerializedName("casts") val casts: List<Cast>? = null ) /*
- 預報天氣信息數據項 / data class Cast( /*
- 日期 / @SerializedName("date") val date: String? = null, /*
- 星期幾 / @SerializedName("week") val week: String? = null, /*
- 白天天氣現象 / @SerializedName("dayweather") val dayweather: String? = null, /*
- 晚上天氣現象 / @SerializedName("nightweather") val nightweather: String? = null, /*
- 白天温度 / @SerializedName("daytemp") val daytemp: String? = null, /*
- 晚上温度 / @SerializedName("nighttemp") val nighttemp: String? = null, /*
- 白天風向 / @SerializedName("daywind") val daywind: String? = null, /*
- 晚上風向 / @SerializedName("nightwind") val nightwind: String? = null, /*
- 白天風力 / @SerializedName("daypower") val daypower: String? = null, /*
- 晚上風力 */ @SerializedName("nightpower") val nightpower: String? = null ) 3.2 天氣API調用封裝 使用OkHttp封裝高德天氣API調用:
import android.util.Log import com.google.gson.Gson import okhttp3.* import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import java.io.IOException import java.util.concurrent.TimeUnit /**
-
天氣API調用輔助類
-
封裝高德地圖天氣API的調用邏輯 */ class WeatherApiHelper { companion object { private const val TAG = "WeatherApiHelper"
// 高德天氣API基礎URL private const val BASE_URL = "https://restapi.amap.com/v3/weather/weatherInfo" // 注意:實際使用時需要在應用中配置API Key // 這裏使用佔位符,實際使用時應該從配置文件或環境變量讀取 private const val API_KEY = "YOUR_AMAP_API_KEY" // HTTP客户端 private val client = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .build() private val gson = Gson()} /**
-
獲取實時天氣信息(基礎版)
-
@param cityCode 城市編碼(adcode),例如:110101(北京東城區)
-
@param callback 回調接口,返回天氣數據或錯誤信息 / fun getWeatherLive( cityCode: String, callback: WeatherCallback ) { getWeather(cityCode, "base", callback) } /*
-
獲取天氣預報信息(包含未來3天)
-
@param cityCode 城市編碼(adcode)
-
@param callback 回調接口,返回天氣數據或錯誤信息 / fun getWeatherForecast( cityCode: String, callback: WeatherCallback ) { getWeather(cityCode, "all", callback) } /*
-
獲取天氣信息
-
@param cityCode 城市編碼
-
@param extensions base:返回實時天氣, all:返回預報天氣
-
@param callback 回調接口 */ private fun getWeather( cityCode: String, extensions: String, callback: WeatherCallback ) { if (API_KEY == "YOUR_AMAP_API_KEY") { callback.onError("請先配置高德地圖API Key") return } val url = "$BASE_URL?city=$cityCode&extensions=$extensions&output=JSON&key=$API_KEY"
Log.d(TAG, "請求天氣API: $url")
val request = Request.Builder() .url(url) .get() .build() client.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { Log.e(TAG, "天氣API請求失敗", e) callback.onError("網絡請求失敗: ${e.message}") } override fun onResponse(call: Call, response: Response) { try { val responseBody = response.body?.string() if (!response.isSuccessful || responseBody == null) { callback.onError("API請求失敗: HTTP ${response.code}") return } Log.d(TAG, "天氣API響應: $responseBody") val weatherResponse = gson.fromJson(responseBody, WeatherApiResponse::class.java)
if (weatherResponse.status == "1" && weatherResponse.info == "OK") { callback.onSuccess(weatherResponse) } else { callback.onError("API返回錯誤: ${weatherResponse.info} (${weatherResponse.infocode})") } } catch (e: Exception) { Log.e(TAG, "解析天氣數據失敗", e) callback.onError("解析數據失敗: ${e.message}") } finally { response.close() } }}) } /**
-
天氣API回調接口 / interface WeatherCallback { /*
- 請求成功
- @param response 天氣響應數據 / fun onSuccess(response: WeatherApiResponse) /*
- 請求失敗
- @param error 錯誤信息 / fun onError(error: String) } /*
-
常用城市編碼(部分示例) */ object CityCodes { // 北京 const val BEIJING_DONGCHENG = "110101" // 東城區 const val BEIJING_HAIDIAN = "110108" // 海淀區
// 上海 const val SHANGHAI_HUANGPU = "310101" // 黃浦區 const val SHANGHAI_PUDONG = "310115" // 浦東新區
// 深圳 const val SHENZHEN_FUTIAN = "440304" // 福田區 const val SHENZHEN_NANSHAN = "440305" // 南山區
// 杭州 const val HANGZHOU_XIHU = "330106" // 西湖區 const val HANGZHOU_SHANGCHENG = "330102" // 上城區
// 廣州 const val GUANGZHOU_TIANHE = "440106" // 天河區 } } 注意事項: 需要在高德開放平台申請API Key 城市編碼(adcode)可通過高德API獲取,常用編碼參考WeatherApiHelper.CityCodes 3.3 自定義界面JSON生成 根據天氣數據生成Rokid眼鏡自定義界面的JSON格式:
-
import android.util.Log import com.google.gson.Gson import com.google.gson.JsonObject import org.json.JSONArray import org.json.JSONObject /**
-
天氣界面JSON生成工具
-
根據天氣數據生成Rokid眼鏡自定義界面的JSON格式 */ class WeatherViewHelper { companion object { private const val TAG = "WeatherViewHelper"
// 自定義界面JSON中的控件ID object ViewIds { const val TV_CITY = "tv_city" const val TV_TEMPERATURE = "tv_temperature" const val TV_WEATHER = "tv_weather" const val TV_WIND = "tv_wind" const val TV_HUMIDITY = "tv_humidity" const val TV_TIME = "tv_time" const val TV_FORECAST_DAY1 = "tv_forecast_day1" const val TV_FORECAST_DAY2 = "tv_forecast_day2" const val TV_FORECAST_DAY3 = "tv_forecast_day3" }} /**
-
生成天氣界面的初始化JSON
-
@param live 實時天氣數據(可為null)
-
@param forecast 預報天氣數據(可為null)
-
@return 自定義界面的JSON字符串 */ fun generateWeatherViewJson( live: Live? = null, forecast: Forecast? = null ): String { val root = JSONObject()
// 根佈局:LinearLayout(垂直方向) root.put("type", "LinearLayout")
val props = JSONObject() props.put("layout_width", "match_parent") props.put("layout_height", "match_parent") props.put("orientation", "vertical") props.put("gravity", "center_horizontal") props.put("paddingTop", "80dp") props.put("paddingBottom", "80dp") props.put("paddingStart", "20dp") props.put("paddingEnd", "20dp") props.put("backgroundColor", "#FF000000") // 黑色背景 root.put("props", props)
val children = JSONArray()
// 1. 城市名稱 children.put(createTextView( id = ViewIds.TV_CITY, text = live?.city ?: "未知城市", textSize = "20sp", textStyle = "bold", marginBottom = "20dp" ))
// 2. 温度(大字體顯示) children.put(createTextView( id = ViewIds.TV_TEMPERATURE, text = "${live?.temperature ?: "--"}°", textSize = "48sp", textStyle = "bold", marginBottom = "15dp" ))
// 3. 天氣狀況 children.put(createTextView( id = ViewIds.TV_WEATHER, text = live?.weather ?: "--", textSize = "18sp", marginBottom = "15dp" ))
// 4. 風向風力(水平佈局) val windLayout = createRelativeLayout( layoutHeight = "wrap_content", marginBottom = "10dp" ) val windLayoutChildren = JSONArray()
windLayoutChildren.put(createTextView( id = ViewIds.TV_WIND, text = "${live?.winddirection ?: "--"} ${live?.windpower ?: "--"}", textSize = "14sp", layoutWidth = "wrap_content", layoutHeight = "wrap_content" )) windLayout.put("children", windLayoutChildren) children.put(windLayout)
// 5. 濕度 children.put(createTextView( id = ViewIds.TV_HUMIDITY, text = "濕度: ${live?.humidity ?: "--"}%", textSize = "14sp", marginBottom = "15dp" ))
// 6. 更新時間 children.put(createTextView( id = ViewIds.TV_TIME, text = "更新: ${live?.reporttime ?: "--"}", textSize = "12sp", textColor = "#FF808080", // 灰色 marginTop = "30dp", marginBottom = "20dp" ))
// 7. 未來3天預報(如果有預報數據) forecast?.casts?.take(3)?.let { casts -> casts.forEachIndexed { index, cast -> val dayText = when (index) { 0 -> "今天" 1 -> "明天" else -> "後天" } val forecastText = "$dayText ${cast.daytemp ?: "--"}°/${cast.nighttemp ?: "--"}° ${cast.dayweather ?: "--"}"
val viewId = when (index) { 0 -> ViewIds.TV_FORECAST_DAY1 1 -> ViewIds.TV_FORECAST_DAY2 else -> ViewIds.TV_FORECAST_DAY3 } children.put(createTextView( id = viewId, text = forecastText, textSize = "14sp", marginBottom = if (index < 2) "8dp" else "0dp" )) }}
root.put("children", children)
val jsonString = root.toString() Log.d(TAG, "生成的天氣界面JSON: $jsonString") return jsonString } /**
-
生成更新天氣界面的JSON(僅更新部分控件)
-
@param live 實時天氣數據
-
@param forecast 預報天氣數據
-
@return 更新操作的JSON數組 */ fun generateWeatherUpdateJson( live: Live? = null, forecast: Forecast? = null ): String { val updates = JSONArray()
live?.let { // 更新城市 if (!it.city.isNullOrEmpty()) { updates.put(createUpdateAction(ViewIds.TV_CITY, "text", it.city)) }
// 更新温度 if (!it.temperature.isNullOrEmpty()) { updates.put(createUpdateAction(ViewIds.TV_TEMPERATURE, "text", "${it.temperature}°")) } // 更新天氣 if (!it.weather.isNullOrEmpty()) { updates.put(createUpdateAction(ViewIds.TV_WEATHER, "text", it.weather)) } // 更新風向風力 val windText = "${it.winddirection ?: "--"} ${it.windpower ?: "--"}" updates.put(createUpdateAction(ViewIds.TV_WIND, "text", windText)) // 更新濕度 if (!it.humidity.isNullOrEmpty()) { updates.put(createUpdateAction(ViewIds.TV_HUMIDITY, "text", "濕度: ${it.humidity}%")) } // 更新時間 if (!it.reporttime.isNullOrEmpty()) { updates.put(createUpdateAction(ViewIds.TV_TIME, "text", "更新: ${it.reporttime}")) }}
// 更新預報 forecast?.casts?.take(3)?.forEachIndexed { index, cast -> val dayText = when (index) { 0 -> "今天" 1 -> "明天" else -> "後天" } val forecastText = "$dayText ${cast.daytemp ?: "--"}°/${cast.nighttemp ?: "--"}° ${cast.dayweather ?: "--"}"
val viewId = when (index) { 0 -> ViewIds.TV_FORECAST_DAY1 1 -> ViewIds.TV_FORECAST_DAY2 else -> ViewIds.TV_FORECAST_DAY3 } updates.put(createUpdateAction(viewId, "text", forecastText))}
val jsonString = updates.toString() Log.d(TAG, "生成的天氣更新JSON: $jsonString") return jsonString } /**
-
創建TextView控件 */ private fun createTextView( id: String, text: String, textSize: String = "16sp", textColor: String = "#FF00FF00", // 綠色(眼鏡端顯示) textStyle: String? = null, layoutWidth: String = "wrap_content", layoutHeight: String = "wrap_content", gravity: String = "center", marginTop: String? = null, marginBottom: String? = null, marginStart: String? = null, marginEnd: String? = null ): JSONObject { val view = JSONObject() view.put("type", "TextView")
val props = JSONObject() props.put("id", id) props.put("layout_width", layoutWidth) props.put("layout_height", layoutHeight) props.put("text", text) props.put("textSize", textSize) props.put("textColor", textColor) props.put("gravity", gravity)
textStyle?.let { props.put("textStyle", it) } marginTop?.let { props.put("marginTop", it) } marginBottom?.let { props.put("marginBottom", it) } marginStart?.let { props.put("marginStart", it) } marginEnd?.let { props.put("marginEnd", it) }
view.put("props", props) return view } /**
-
創建RelativeLayout佈局 */ private fun createRelativeLayout( layoutWidth: String = "match_parent", layoutHeight: String = "wrap_content", backgroundColor: String = "#00000000", marginTop: String? = null, marginBottom: String? = null ): JSONObject { val layout = JSONObject() layout.put("type", "RelativeLayout")
val props = JSONObject() props.put("layout_width", layoutWidth) props.put("layout_height", layoutHeight) props.put("backgroundColor", backgroundColor)
marginTop?.let { props.put("marginTop", it) } marginBottom?.let { props.put("marginBottom", it) }
layout.put("props", props) return layout } /**
-
創建更新操作 */ private fun createUpdateAction( id: String, propName: String, propValue: Any ): JSONObject { val action = JSONObject() action.put("action", "update") action.put("id", id)
val props = JSONObject() props.put(propName, propValue) action.put("props", props)
return action } /**
-
生成天氣TTS播報文本
-
@param live 實時天氣數據
-
@param forecast 預報天氣數據
-
@return TTS播報文本 */ fun generateWeatherTtsText( live: Live?, forecast: Forecast? = null ): String { if (live == null) { return "天氣數據獲取失敗" } val city = live.city ?: "當前城市" val temperature = live.temperature ?: "--" val weather = live.weather ?: "未知" val wind = "${live.winddirection ?: ""} ${live.windpower ?: ""}".trim()
val ttsText = StringBuilder() ttsText.append("$city 當前天氣,") ttsText.append("温度 $temperature 度,") ttsText.append("$weather")
if (wind.isNotEmpty()) { ttsText.append(",$wind") }
// 添加預報信息 forecast?.casts?.firstOrNull()?.let { cast -> val tomorrowTemp = cast.daytemp ?: "--" val tomorrowWeather = cast.dayweather ?: "--" ttsText.append("。明天 $tomorrowWeather,温度 $tomorrowTemp 度") }
return ttsText.toString() } } 自定義界面JSON格式説明: 支持佈局:LinearLayout、RelativeLayout 支持控件:TextView、ImageView 顏色格式:#FF00FF00(ARGB,綠色在眼鏡端顯示) 尺寸單位:dp(佈局)、sp(文字) 3.4 TTS播報文本生成 生成適合TTS播報的天氣摘要文本:
-
// WeatherViewHelper.kt fun generateWeatherTtsText( live: Live?, forecast: Forecast? = null ): String { if (live == null) { return "天氣數據獲取失敗" } val city = live.city ?: "當前城市" val temperature = live.temperature ?: "--" val weather = live.weather ?: "未知" val wind = "${live.winddirection ?: ""} ${live.windpower ?: ""}".trim()
val ttsText = StringBuilder()
ttsText.append("$city 當前天氣,")
ttsText.append("温度 $temperature 度,")
ttsText.append("$weather")
if (wind.isNotEmpty()) {
ttsText.append(",$wind")
}
// 添加明天預報
forecast?.casts?.firstOrNull()?.let { cast ->
val tomorrowTemp = cast.daytemp ?: "--"
val tomorrowWeather = cast.dayweather ?: "--"
ttsText.append("。明天 $tomorrowWeather,温度 $tomorrowTemp 度")
}
return ttsText.toString()
} 3.5 主Activity整合 在WeatherActivity中整合所有功能:
/**
-
天氣應用Activity - Rokid眼鏡端天氣顯示示例
-
功能:
-
- 調用高德天氣API獲取天氣數據
-
- 在眼鏡端使用自定義界面顯示天氣信息
-
- 使用TTS語音播報天氣信息
-
- 支持更新天氣界面 */ class WeatherActivity : AppCompatActivity() { companion object { private const val TAG = "WeatherActivity" } private lateinit var tvStatus: TextView private lateinit var etCityCode: EditText private lateinit var btnQueryWeather: Button private lateinit var btnShowWeather: Button private lateinit var btnUpdateWeather: Button private lateinit var btnTtsWeather: Button private lateinit var btnCloseView: Button private val weatherApiHelper = WeatherApiHelper() private val weatherViewHelper = WeatherViewHelper()
private var currentWeatherResponse: WeatherApiResponse? = null private var isCustomViewOpened = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_weather) initViews() setupCustomViewListener()
// 默認城市編碼:北京東城區 etCityCode.setText(WeatherApiHelper.CityCodes.BEIJING_DONGCHENG) updateStatus("天氣應用已啓動,請先連接Rokid設備")} private fun initViews() { tvStatus = findViewById(R.id.tvStatus) etCityCode = findViewById(R.id.etCityCode) btnQueryWeather = findViewById(R.id.btnQueryWeather) btnShowWeather = findViewById(R.id.btnShowWeather) btnUpdateWeather = findViewById(R.id.btnUpdateWeather) btnTtsWeather = findViewById(R.id.btnTtsWeather) btnCloseView = findViewById(R.id.btnCloseView) btnQueryWeather.setOnClickListener { queryWeather() } btnShowWeather.setOnClickListener { showWeatherOnGlasses() } btnUpdateWeather.setOnClickListener { updateWeatherOnGlasses() } btnTtsWeather.setOnClickListener { ttsWeatherOnGlasses() } btnCloseView.setOnClickListener { closeCustomView() }
// 初始狀態:只有查詢天氣按鈕可用 btnShowWeather.isEnabled = false btnUpdateWeather.isEnabled = false btnTtsWeather.isEnabled = false btnCloseView.isEnabled = false} /**
-
設置自定義界面監聽器
-
監聽眼鏡端自定義界面的狀態變化 / private fun setupCustomViewListener() { val customViewListener = object : CustomViewListener { override fun onOpened() { Log.d(TAG, "自定義界面已打開") runOnUiThread { updateStatus("自定義界面已打開") isCustomViewOpened = true btnUpdateWeather.isEnabled = true btnCloseView.isEnabled = true } } override fun onClosed() { Log.d(TAG, "自定義界面已關閉") runOnUiThread { updateStatus("自定義界面已關閉") isCustomViewOpened = false btnUpdateWeather.isEnabled = false btnCloseView.isEnabled = false } } override fun onUpdated() { Log.d(TAG, "自定義界面已更新") runOnUiThread { updateStatus("自定義界面已更新") } } override fun onOpenFailed(errorCode: Int) { Log.e(TAG, "自定義界面打開失敗: $errorCode") runOnUiThread { updateStatus("自定義界面打開失敗: $errorCode") isCustomViewOpened = false } } override fun onIconsSent() { Log.d(TAG, "圖標已發送") runOnUiThread { updateStatus("圖標已發送") } } } CxrApi.getInstance().setCustomViewListener(customViewListener) } /*
-
查詢天氣數據 */ private fun queryWeather() { val cityCode = etCityCode.text.toString().trim() if (cityCode.isEmpty()) { updateStatus("請輸入城市編碼") return } updateStatus("正在查詢天氣...") btnQueryWeather.isEnabled = false // 獲取實時天氣和預報天氣 weatherApiHelper.getWeatherForecast(cityCode, object : WeatherApiHelper.WeatherCallback { override fun onSuccess(response: WeatherApiResponse) { Log.d(TAG, "天氣查詢成功: $response") currentWeatherResponse = response runOnUiThread { val live = response.lives?.firstOrNull() val cityName = live?.city ?: "未知城市" val temperature = live?.temperature ?: "--" val weather = live?.weather ?: "--"
updateStatus("查詢成功: $cityName $temperature° $weather") btnQueryWeather.isEnabled = true btnShowWeather.isEnabled = true btnTtsWeather.isEnabled = true } }
override fun onError(error: String) { Log.e(TAG, "天氣查詢失敗: $error") runOnUiThread { updateStatus("查詢失敗: $error") btnQueryWeather.isEnabled = true btnShowWeather.isEnabled = false btnTtsWeather.isEnabled = false } } }) } /** * 在眼鏡端顯示天氣界面 * 使用自定義界面(Custom View)功能 */ private fun showWeatherOnGlasses() { if (!checkBluetoothConnected()) { return } val response = currentWeatherResponse if (response == null) { updateStatus("請先查詢天氣數據") return } val live = response.lives?.firstOrNull() val forecast = response.forecasts?.firstOrNull() // 生成自定義界面JSON val viewJson = weatherViewHelper.generateWeatherViewJson(live, forecast)
Log.d(TAG, "打開自定義界面: $viewJson")
// 打開自定義界面
val status = CxrApi.getInstance().openCustomView(viewJson)
handleRequestStatus(
status = status,
successMessage = "正在打開天氣界面...",
waitingMessage = "請求處理中,請稍候...",
failedMessage = "打開天氣界面失敗"
)
}
/** * 更新眼鏡端的天氣界面 */ private fun updateWeatherOnGlasses() { if (!checkBluetoothConnected()) { return } if (!isCustomViewOpened) { updateStatus("請先打開天氣界面") return } val response = currentWeatherResponse if (response == null) { updateStatus("請先查詢天氣數據") return } val live = response.lives?.firstOrNull() val forecast = response.forecasts?.firstOrNull() // 生成更新JSON val updateJson = weatherViewHelper.generateWeatherUpdateJson(live, forecast)
Log.d(TAG, "更新天氣界面: $updateJson")
// 更新自定義界面
val status = CxrApi.getInstance().updateCustomView(updateJson)
handleRequestStatus(
status = status,
successMessage = "正在更新天氣界面...",
waitingMessage = "更新請求處理中,請稍候...",
failedMessage = "更新天氣界面失敗"
)
}
/** * 在眼鏡端使用TTS播報天氣 * 使用全局TTS功能 */ private fun ttsWeatherOnGlasses() { if (!checkBluetoothConnected()) { return } val response = currentWeatherResponse if (response == null) { updateStatus("請先查詢天氣數據") return } val live = response.lives?.firstOrNull() val forecast = response.forecasts?.firstOrNull() // 生成TTS文本 val ttsText = weatherViewHelper.generateWeatherTtsText(live, forecast)
Log.d(TAG, "播報天氣TTS: $ttsText")
// 發送全局TTS消息
val status = CxrApi.getInstance().sendGlobalTtsContent(ttsText)
handleRequestStatus(
status = status,
successMessage = "正在播報天氣...",
waitingMessage = "TTS請求處理中,請稍候...",
failedMessage = "TTS播報失敗"
)
}
/** * 關閉眼鏡端的自定義界面 / private fun closeCustomView() { if (!checkBluetoothConnected()) { return } val status = CxrApi.getInstance().closeCustomView() handleRequestStatus( status = status, successMessage = "正在關閉天氣界面...", waitingMessage = "關閉請求處理中,請稍候...", failedMessage = "關閉天氣界面失敗" ) } /* * 統一處理API請求狀態 * * @param status API返回的狀態 * @param successMessage 成功時的提示信息 * @param waitingMessage 等待時的提示信息 * @param failedMessage 失敗時的提示信息 / private fun handleRequestStatus( status: ValueUtil.CxrStatus, successMessage: String, waitingMessage: String = "請求處理中,請稍候...", failedMessage: String = "操作失敗" ) { when (status) { ValueUtil.CxrStatus.REQUEST_SUCCEED -> { updateStatus(successMessage) } ValueUtil.CxrStatus.REQUEST_WAITING -> { updateStatus(waitingMessage) } ValueUtil.CxrStatus.REQUEST_FAILED -> { updateStatus(failedMessage) } else -> { // 處理意外狀態(理論上不應該出現) Log.w(TAG, "收到意外的狀態: $status") updateStatus("未知狀態: $status") } } } /* * 檢查藍牙連接狀態 / private fun checkBluetoothConnected(): Boolean { val isConnected = CxrApi.getInstance().isBluetoothConnected() if (!isConnected) { updateStatus("請先連接Rokid設備") } return isConnected } /* * 更新狀態顯示 */ private fun updateStatus(message: String) { Log.d(TAG, message) tvStatus.text = "狀態: $message" } override fun onDestroy() { super.onDestroy() // 清理自定義界面監聽器 CxrApi.getInstance().setCustomViewListener(null) } }
四、眼鏡端交互處理 4.1 自定義界面系統級支持 重要説明:Rokid眼鏡端的自定義界面(Custom View)是系統級功能,不需要在眼鏡端編寫額外的代碼。移動端通過openCustomView()發送的JSON會自動在眼鏡端渲染顯示。 交互流程:
移動端 openCustomView(json) ↓ 藍牙/WiFi傳輸 ↓ 眼鏡端系統自動渲染顯示 4.2 消息通道交互方式 如果需要在眼鏡端實現更復雜的交互(比如接收天氣數據更新、發送反饋等),可以使用消息通道方式。 4.2.1 接收移動端消息 眼鏡端通過CustomCmdListener接收來自移動端的消息:
// GlassesMainActivity.kt (眼鏡端) class GlassesMainActivity : AppCompatActivity() { companion object { private const val TAG = "GlassesMain" private const val CHANNEL_WEATHER = "weather_update" private const val CHANNEL_WEATHER_REFRESH = "weather_refresh" } private val gson = Gson() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setupMessageListener() } /** * 設置消息監聽器 * 接收來自移動端的天氣相關消息 */ private fun setupMessageListener() { CxrApi.getInstance().setCustomCmdListener(object : CustomCmdListener { override fun onCustomCmd(name: String, args: Caps?) { Log.d(TAG, "收到命令: channel=$name, args.size=${args?.size()}")
when (name) {
CHANNEL_WEATHER -> handleWeatherUpdate(args)
CHANNEL_WEATHER_REFRESH -> handleWeatherRefresh(args)
else -> Log.w(TAG, "未知通道: $name")
}
}
})
}
/** * 處理天氣數據更新 */ private fun handleWeatherUpdate(caps: Caps?) { if (caps == null || caps.size() < 2) { Log.e(TAG, "handleWeatherUpdate: caps 格式錯誤") return } try { // 按發送端寫入順序解析: // 第1個:子命令 (如 "WEATHER_UPDATE") // 第2個:載荷 (JSON字符串) val subCommand = caps.at(0).getString() val payloadJson = caps.at(1).getString()
Log.d(TAG, "子命令: $subCommand, 載荷: $payloadJson")
when (subCommand) { "WEATHER_UPDATE" -> { // 解析天氣數據JSON val weatherData = gson.fromJson(payloadJson, Map::class.java) as? Map<String, Any?> val city = weatherData?.get("city") as? String val temperature = weatherData?.get("temperature") as? String val weather = weatherData?.get("weather") as? String
// 更新UI顯示
updateWeatherDisplay(city, temperature, weather)
// 可選:發送ACK確認
sendAckToMobile("天氣數據已接收: $city $temperature°")
}
"WEATHER_FORECAST" -> {
// 處理預報數據
handleForecastUpdate(payloadJson)
}
else -> {
Log.w(TAG, "未知子命令: $subCommand")
}
}
} catch (e: Exception) {
Log.e(TAG, "解析天氣更新失敗", e)
}
}
/** * 處理天氣刷新請求 */ private fun handleWeatherRefresh(caps: Caps?) { // 眼鏡端可以請求移動端刷新天氣 // 例如:用户通過眼鏡按鍵觸發刷新 Log.d(TAG, "收到天氣刷新請求")
// 可選:發送請求到移動端
requestMobileRefresh()
}
/** * 更新天氣顯示 / private fun updateWeatherDisplay(city: String?, temp: String?, weather: String?) { runOnUiThread { // 更新UI顯示 // 注意:如果使用自定義界面,這部分由系統自動處理 Log.d(TAG, "更新顯示: $city $temp° $weather") } } /* * 發送確認消息給移動端 */ private fun sendAckToMobile(message: String) { try { val ackJson = gson.toJson(mapOf( "code" to 0, "message" to message, "timestamp" to System.currentTimeMillis() ))
val caps = Caps().apply {
write("WEATHER_ACK")
write(ackJson)
}
CxrApi.getInstance().sendCustomCmd("glass_ack", caps)
Log.d(TAG, "已發送 ACK: $message")
} catch (e: Exception) {
Log.e(TAG, "發送 ACK 失敗", e)
}
}
/** * 請求移動端刷新天氣 */ private fun requestMobileRefresh() { try { val requestJson = gson.toJson(mapOf( "action" to "refresh_weather", "timestamp" to System.currentTimeMillis() ))
val caps = Caps().apply {
write("REFRESH_REQUEST")
write(requestJson)
}
CxrApi.getInstance().sendCustomCmd("glass_weather_request", caps)
Log.d(TAG, "已發送刷新請求")
} catch (e: Exception) {
Log.e(TAG, "發送刷新請求失敗", e)
}
}
override fun onDestroy() { super.onDestroy() CxrApi.getInstance().setCustomCmdListener(null) } } 4.2.2 移動端發送天氣消息(可選擴展) 如果需要在移動端通過消息通道發送天氣數據(而不是使用自定義界面),可以在WeatherActivity中添加:
// WeatherActivity.kt (移動端擴展) private const val CHANNEL_WEATHER = "weather_update" /**
-
通過消息通道發送天氣數據到眼鏡端 */ private fun sendWeatherViaMessage(live: Live?, forecast: Forecast?) { if (!checkBluetoothConnected()) { return } val weatherData = mapOf( "city" to (live?.city ?: "--"), "temperature" to (live?.temperature ?: "--"), "weather" to (live?.weather ?: "--"), "wind" to "${live?.winddirection ?: ""} ${live?.windpower ?: ""}".trim(), "humidity" to (live?.humidity ?: "--"), "timestamp" to System.currentTimeMillis() ) val json = gson.toJson(weatherData)
val caps = Caps().apply { write("WEATHER_UPDATE") // 子命令 write(json) // 載荷 }
val status = CxrApi.getInstance().sendCustomCmd(CHANNEL_WEATHER, caps) handleRequestStatus( status = status, successMessage = "天氣數據已發送", failedMessage = "發送天氣數據失敗" ) } 4.3 雙向交互完整示例 場景:眼鏡端顯示天氣 → 用户操作刷新 → 眼鏡端請求移動端 → 移動端刷新並更新顯示 移動端:監聽眼鏡端刷新請求
// WeatherActivity.kt (移動端) private const val CHANNEL_WEATHER_REQUEST = "glass_weather_request" private fun setupCustomCmdListener() { CxrApi.getInstance().setCustomCmdListener(object : CustomCmdListener { override fun onCustomCmd(name: String, args: Caps?) { when (name) { CHANNEL_WEATHER_REQUEST -> { // 收到眼鏡端的刷新請求 val subCommand = args?.at(0)?.getString() if (subCommand == "REFRESH_REQUEST") { // 自動刷新天氣並更新眼鏡端顯示 refreshWeatherAndUpdateGlasses() } } } } }) } private fun refreshWeatherAndUpdateGlasses() { val cityCode = etCityCode.text.toString().trim() queryWeather() // 刷新天氣數據
// 刷新成功後自動更新眼鏡端顯示
if (isCustomViewOpened) {
updateWeatherOnGlasses()
}
} 眼鏡端:發送刷新請求
// GlassesMainActivity.kt (眼鏡端) /**
-
用户觸發刷新(例如:按鍵、手勢等) */ fun onUserRefreshRequest() { requestMobileRefresh() } private fun requestMobileRefresh() { val requestJson = gson.toJson(mapOf( "action" to "refresh_weather", "timestamp" to System.currentTimeMillis() ))
val caps = Caps().apply { write("REFRESH_REQUEST") write(requestJson) }
CxrApi.getInstance().sendCustomCmd("glass_weather_request", caps) } 4.4 交互方式對比
五、踩坑與排錯速查 5.1 天氣API相關 API Key未配置:在WeatherApiHelper中配置API_KEY 城市編碼錯誤:使用高德API獲取正確的adcode,或參考WeatherApiHelper.CityCodes 網絡請求失敗:檢查網絡權限、網絡連接、API配額 JSON解析失敗:檢查API響應格式,確保數據模型匹配 5.2 自定義界面相關 界面未顯示:檢查藍牙連接狀態、JSON格式是否正確、界面是否打開成功 JSON格式錯誤:參考CXR-M(移動端)自定義界面場景.md,確保格式符合規範 顏色不顯示:使用綠色通道(#FF00FF00),其他顏色在眼鏡端可能不顯示 更新不生效:確保使用正確的控件ID,更新JSON格式正確 5.3 TTS播報相關 TTS不播報:檢查藍牙連接狀態、文本內容是否為空 播報順序混亂:TTS自動處理播放隊列,避免快速連續發送 中文亂碼:確保使用UTF-8編碼 5.4 藍牙連接相關 設備未連接:使用CxrApi.getInstance().isBluetoothConnected()檢查連接狀態 連接斷開:監聽BluetoothStatusCallback.onDisconnected(),處理重連邏輯 請求失敗:確保在連接成功後再調用openCustomView()、sendGlobalTtsContent()等API 5.5 常見錯誤碼 REQUEST_SUCCEED:請求成功 REQUEST_WAITING:請求處理中,不要重複請求 REQUEST_FAILED:請求失敗,檢查連接狀態和參數
六、擴展功能建議 6.1 定時刷新
// 使用Handler或協程定時刷新天氣 private val handler = Handler(Looper.getMainLooper()) private val refreshRunnable = object : Runnable { override fun run() { queryWeather() handler.postDelayed(this, 30 * 60 * 1000) // 30分鐘刷新一次 } } 6.2 位置定位 集成Android定位服務,自動獲取當前城市編碼:
// 使用FusedLocationProviderClient獲取位置 // 然後通過高德逆地理編碼API獲取adcode 6.3 天氣圖標 使用sendCustomViewIcons()上傳天氣圖標(晴、雨、雪等),在自定義界面中使用ImageView顯示。 6.4 全局消息通知 使用sendGlobalMsgContent()或sendGlobalToastContent()在天氣變化時發送通知。 6.5 多城市管理 支持添加多個城市,切換顯示不同城市的天氣信息。
七、最後 本文實現了一個不太完整的天氣應用,充分利用了Rokid眼鏡的自定義界面和TTS語音播報特性。通過這個示例,你可以: 學會調用第三方API並解析數據 掌握自定義界面的JSON格式定義 實現界面動態更新機制 使用TTS進行語音播報 處理眼鏡端連接狀態和錯誤 下一步,你可以基於這個框架實現更多應用場景,比如新聞播報、股票顯示、日程提醒等。只要掌握了自定義界面和TTS的使用,就能快速開發出實用的眼鏡端應用。 如果您有任何疑問、對文章寫的不滿意、發現錯誤或者有更好的方法,如果你想支持下一期請務必點贊~,歡迎在評論、私信或郵件中提出,這對我真的很重要,非常感謝您的支持。🙏
所有代碼均已包含在項目中,可直接參考使用。