You need to enable JavaScript to run this app. 如何在iOS应用中解析Alexa自定义技能返回的响应体为JSON
优惠活动
大模型
产品
解决方案
定价
更多

如何在iOS应用中解析Alexa自定义技能返回的响应体为JSON

问题现状
  • 已完成Alexa自定义技能搭建,可从iOS应用端调用触发该技能
  • 在Alexa控制台模拟器测试时,可直接看到技能返回的完整JSON结构
  • iOS端代码运行正常,可正常播放自定义技能返回的语音文本响应,但无法提取目标结构化JSON内容

目标提取的JSON结构示例:

{
    "body": {
        "version": "1.0",
        "response": {
            "outputSpeech": {
                "type": "SSML",
                "ssml": "<speak>Hello this is a custom skill</speak>"
            },
            "type": "_DEFAULT_RESPONSE"
        },
        "sessionAttributes": {},
        "userAgent": "ask-node/2.9.0 Node/v10.23.1 sample/hello-world/v1.2"
    }
}
现有代码实现
func postRecording(authToken:String, jsonData:String, audioData: Data) {
    var request = URLRequest(url: URL(string: EVENTS_ENDPOINT)!)
    request.httpShouldHandleCookies = false
    request.timeoutInterval = 60
    request.httpMethod = "POST"
    request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
    
    let boundry = NSUUID().uuidString
    let contentType = "multipart/form-data; boundary=\(boundry)"
    
    request.setValue(contentType, forHTTPHeaderField: "Content-Type")
    
    var bodyData = Data()
    
    bodyData.append("--\(boundry)\r\n".data(using: String.Encoding.utf8)!)
    bodyData.append("Content-Disposition: form-data; name=\"metadata\"\r\n".data(using: String.Encoding.utf8)!)
    bodyData.append("Content-Type: application/json; charset=UTF-8\r\n\r\n".data(using: String.Encoding.utf8)!)
    bodyData.append(jsonData.data(using: String.Encoding.utf8)!)
    bodyData.append("\r\n".data(using: String.Encoding.utf8)!)
    
    bodyData.append("--\(boundry)\r\n".data(using: String.Encoding.utf8)!)
    bodyData.append("Content-Disposition: form-data; name=\"audio\"\r\n".data(using: String.Encoding.utf8)!)
    bodyData.append("Content-Type: audio/L16; rate=16000; channels=1\r\n\r\n".data(using: String.Encoding.utf8)!)
    bodyData.append(audioData)
    bodyData.append("\r\n".data(using: String.Encoding.utf8)!)
    
    bodyData.append("--\(boundry)--\r\n".data(using: String.Encoding.utf8)!)
    
    session.uploadTask(with: request, from: bodyData, completionHandler: { (data:Data?, response:URLResponse?, error:Error?) -> Void in
        if (error != nil) {
            print("Send audio error: \(String(describing: error?.localizedDescription))")
        } else {
            let res = response as! HTTPURLResponse
            if (res.statusCode >= 200 && res.statusCode <= 299) {
                if let contentTypeHeader = res.allHeaderFields["Content-Type"] {
                    let boundary = self.extractBoundary(contentTypeHeader: contentTypeHeader as! String)
                    let directives = self.extractDirectives(data: data!, boundary: boundary)
                    self.directiveHandler?(directives)
                } else {
                    print("Content type in response is empty")
                }
            }
        }
    }).resume()
}

func extractDirectives(data: Data, boundary: String) -> [DirectiveData] {
    var directives = [DirectiveData]()
    
    let innerBoundry = "--\(boundary)".data(using: String.Encoding.utf8)!
    let endBoundry = "--\(boundary)--".data(using: String.Encoding.utf8)!
    let contentTypeApplicationJson = "Content-Type: application/json; charset=UTF-8".data(using: String.Encoding.utf8)!
    let contentTypeAudio = "Content-Type: application/octet-stream".data(using: String.Encoding.utf8)!
    let headerEnd = "\r\n\r\n".data(using: String.Encoding.utf8)!
    
    var startIndex = 0
    while (true) {
        let firstAppearance = data.range(of: innerBoundry, in: startIndex..<(data.count))
        if (firstAppearance == nil) {
            break
        }
        var secondAppearance = data.range(of: innerBoundry, in: (firstAppearance?.upperBound)!..<(data.count))
        if (secondAppearance == nil) {
            secondAppearance = data.range(of: endBoundry, in: (firstAppearance?.upperBound)!..<(data.count))
            if (secondAppearance == nil) {
                break
            }
        } else {
            startIndex = (secondAppearance?.lowerBound)!
        }
        let subdata = data.subdata(in: (firstAppearance?.upperBound)!..<(secondAppearance?.lowerBound)!)
        var contentType = subdata.range(of: contentTypeApplicationJson)
        if (contentType != nil) {
            let headerRange = subdata.range(of: headerEnd)
            var directiveData = String(data: subdata.subdata(in: (headerRange?.upperBound)!..<subdata.count), encoding: String.Encoding.utf8) ?? "Directive data is not String"
            directiveData = directiveData.replacingOccurrences(of: "\r\n", with: "")
            directives.append(DirectiveData(contentType: "application/json", data: directiveData.data(using: String.Encoding.utf8)!))
            print("Directive: \(directiveData)")
        }
        contentType = subdata.range(of: contentTypeAudio)
        if (contentType != nil) {
            let headerRange = subdata.range(of: headerEnd)
            let audioData = subdata.subdata(in: (headerRange?.upperBound)!..<subdata.count)
            directives.append(DirectiveData(contentType: "application/octet-stream", data: audioData))
            print("Audio data")
        }
    }
    return directives
}
当前解析的实际返回

现在代码运行后,仅能解析得到SpeechSynthesizer命名空间下的Speak指令,结构如下:

{
    "header": {
        "namespace": "SpeechSynthesizer",
        "name": "Speak",
        "messageId": "35601dca-413f-4788-93ed-305e04328d0e",
        "dialogRequestId": "db933fd9-3766-407d-99f6-2b33d9c814e6",
        "keys": {
            "isBlocking": true,
            "channel": "audio"
        }
    },
    "payload": {
        "format": "AUDIO_MPEG",
        "token": "amzn1.as-ct.v1.ThirdPartySdkSpeechlet#ACRI#ValidatedSpeakDirective_amzn1.ask.skill.5718d3ec-b56a-4a5a-bd61-fdd4ab501fc2_ca3ae40b-52cd-4be9-b831-98e975a57e5a_VoiceInitiated#ACRI#[[ENCRYPTED_WITH_AlexaServiceKeyMasterUtil]]AAAAAAAAAAAHT6zrrzQh3EDQVwUGrjOpUAAAAAAAAACFZJ/VU0i9/OCY89UvtcV9X0291NLF7L7nE4irlDAGaph7NlUpgq0Ps31s8OflwHh/9wr3vp5Py+Gz+2Lgd8N6fPXWxPHp8+E6R8sLiRh9Cg=="
    }
}
可落地的实现方案

核心原因是:当前调用的EVENTS_ENDPOINT是Alexa Voice Service(AVS)的语音事件上报接口,接口返回的是AVS协议层封装后的标准指令集合,不会直接透传Alexa技能平台内部的原始响应JSON。你在控制台看到的技能返回结构,是Alexa技能计算服务流转的内部数据,AVS客户端接口默认不会将这部分原始数据直接下发给移动端。要拿到目标结构化数据,可按以下步骤调整:

  • 调整技能端的响应结构:将你需要在客户端获取的全量业务数据,存入技能返回的sessionAttributes字段中。AVS网关会完整透传sessionAttributes的所有内容,不会像outputSpeech字段一样被转换为可播放的音频指令。示例调整方式:
    {
        "version": "1.0",
        "response": {
            "outputSpeech": {
                "type": "SSML",
                "ssml": "<speak>Hello this is a custom skill</speak>"
            },
            "type": "_DEFAULT_RESPONSE"
        },
        "sessionAttributes": {
            "rawSkillResponse": {
                "version": "1.0",
                "response": {
                    "outputSpeech": {
                        "type": "SSML",
                        "ssml": "<speak>Hello this is a custom skill</speak>"
                    },
                    "type": "_DEFAULT_RESPONSE"
                },
                "userAgent": "ask-node/2.9.0 Node/v10.23.1 sample/hello-world/v1.2"
            }
        }
    }
    
  • 修复iOS端的多part解析逻辑:当前extractDirectives方法仅硬编码匹配了两种Content-Type的part,会漏掉AVS在响应流末尾附带的Alexa.Context命名空间的UpdateState指令——这部分指令的payload里,就携带了当前会话全量的sessionAttributes内容。你需要在拿到所有JSON类型的directive后,遍历匹配header中namespace == "Alexa.Context"且name == "UpdateState"的条目,即可提取存在sessionAttributes中的业务数据。
  • 优化解析的鲁棒性:现有边界匹配逻辑没有处理part首尾的换行符,容易出现内容截断;同时不要仅靠硬编码匹配Content-Type字符串识别内容类型,要补充解析每个part的Content-Disposition字段,避免因服务端返回的Header格式微调导致解析失效。

内容的提问来源于stack exchange,提问作者elbert rivas

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.11.06 14:06:01