如何在iOS客户端中将外部WebVTT字幕添加到HTTP Live流中

10
我们有通过bitmovin.com编码的视频,并以HTTP Live Streams(Fairplay HLS)的形式提供,但是字幕虽然以WebVTT格式存在,但作为单独的URL公开,而不是单个段的一部分,并且不属于HLS m3u8播放列表的一部分。
我正在寻找一种方法,使得外部下载的.vtt文件仍然可以被包含在HLS流中,并且可以作为AVPlayer中的字幕可用。
我知道苹果公司的建议是将分段的VTT字幕包含在HLS播放列表中,但我现在无法更改服务器实现,因此我想澄清是否可能向AVPlayer提供字幕以与HLS流一起播放。
关于这个问题唯一有效的帖子是这个:Subtitles for AVPlayer/MPMoviePlayerController。然而,示例代码从捆绑包中加载本地mp4文件,我正在努力使其适用于通过AVURLAsset的m3u8播放列表。实际上,我无法从远程m3u8流中获取videoTrack,因为asset.tracks(withMediaType: AVMediaTypeVideo)返回空数组。有任何想法吗?这种方法是否适用于真正的HLS流?或者是否有其他方法可以在不将它们包含在服务器的HLS播放列表中的情况下播放单独的WebVTT字幕与HLS流?谢谢。
func playFpsVideo(with asset: AVURLAsset, at context: UIViewController) {

    let composition = AVMutableComposition()

    // Video
    let videoTrack = composition.addMutableTrack(withMediaType: AVMediaTypeVideo, preferredTrackID: kCMPersistentTrackID_Invalid)

    do {

        let tracks = asset.tracks(withMediaType: AVMediaTypeVideo)

        // ==> The code breaks here, tracks is an empty array
        guard let track = tracks.first else {
            Log.error("Can't get first video track")
            return
        }

        try videoTrack.insertTimeRange(CMTimeRangeMake(kCMTimeZero, asset.duration), of: track, at: kCMTimeZero)

    } catch {

        Log.error(error)
        return
    }


    // Subtitle, some test from the bundle..
    guard let subsUrl = Bundle.main.url(forResource: "subs", withExtension: "vtt") else {
        Log.error("Can't load subs.vtt from bundle")
        return
    }

    let subtitleAsset = AVURLAsset(url: subsUrl)

    let subtitleTrack = composition.addMutableTrack(withMediaType: AVMediaTypeText, preferredTrackID: kCMPersistentTrackID_Invalid)

    do {

        let subTracks = subtitleAsset.tracks(withMediaType: AVMediaTypeText)

        guard let subTrack = subTracks.first else {
            Log.error("Can't get first subs track")
            return
        }

        try subtitleTrack.insertTimeRange(CMTimeRangeMake(kCMTimeZero, asset.duration), of: subTrack, at: kCMTimeZero)

    } catch {

        Log.error(error)
        return
    }


    // Prepare item and play it
    let item = AVPlayerItem(asset: composition)

    let player = AVPlayer(playerItem: item)

    let playerViewController = AVPlayerViewController()
    playerViewController.player = player

    self.playerViewController = playerViewController

    context.present(playerViewController, animated: true) {
        playerViewController.player?.play()
    }
}

有没有解决这个问题的运气?我也遇到了同样的问题。谢谢。 - Mihai Panţiru
1
在该设备上无法添加字幕。我通过修改m3u8播放列表,在后端解决了这个问题,创建了带有字幕轨道的有效播放列表。我曾与链接问题答案的作者进行过有效的交流,但由于它没有回答问题而被删除。无论如何,在对话中我们得出结论,他使用的是直接mp4流,而不是m3u8播放列表,因此不可能实现添加字幕。 - Martin Koles
你好,@MartinKoles,你找到解决办法了吗? - allenlinli
如@allenlinli所述,无法在客户端上将字幕添加到流中。必须在后端完成,并提供带有字幕轨道的适当播放列表。 - Martin Koles
2个回答

6

我弄清楚了这个问题。花费了很长时间,我非常讨厌它。我将我的解释和源代码放在Github上,但如果链接失效,我也会在这里提供: https://github.com/kanderson-wellbeats/sideloadWebVttToAVPlayer

我在这里放置这个解释,试图为一些未来的人节省很多痛苦。我找到的很多东西都是错的,或者漏掉了困惑的部分,或者有很多额外的无关信息,或者混合了这三者。除此之外,我看到很多人寻求帮助,试图做同样的事情,但没有人提供清晰的答案。

所以首先我将描述我要做的事情。我的后端服务器是Azure媒体服务,在需要流式传输不同分辨率视频方面非常出色,但实际上它并不太支持WebVtt。是的,您可以在那里托管文件,但似乎它不能给我们一个包括对字幕播放列表的引用的主播放列表(如Apple要求的)。看起来,微软和Apple在大约2012年就决定了他们要如何处理字幕,并且从那时起就没有再碰过它。当时他们要么没有相互交流,要么故意朝着相反的方向走,但他们恰好具有差劲的互操作性,现在像我们这样的开发人员被迫拉伸巨头之间的差距。许多在线资源涵盖此主题,但它们更加混乱而不是有用。我想做的所有事情只是在由Azure媒体服务提供HLS协议播放的点播视频中添加字幕 - 没有更多,也没有更少。我将从文字上描述一切,然后在最后放置实际代码。

以下是极度简化版的步骤:

  1. 拦截主播放列表请求并返回一个编辑好的版本,其中引用了字幕播放列表(多种语言需要多个,或者只有一种语言需要一个)
  2. 选择要显示的字幕(在https://developer.apple.com/documentation/avfoundation/media_playback_and_selection/selecting_subtitles_and_alternative_audio_tracks 上有详细说明)
  3. 拦截即将到来的字幕播放列表请求(在您选择要显示字幕后),并返回您实时构建的引用服务器上WebVtt文件的播放列表

就是这样。没有太多的东西,但有很多复杂性会妨碍您,我不得不自己发现它们。首先我将简要描述每个复杂性,然后详细描述。

简要的复杂性解释:

  1. 虽然会有许多请求通过,但只有一小部分请求需要(且能够)由你处理,其他请求需要保持原样。我将描述哪些请求需要处理,哪些不需要以及如何处理它们。
  2. 苹果认为简单的HTTP请求不够好,因此决定将其转换为奇怪的双重身份AVAssetResourceLoadingRequest对象,并具有一个DataRequest属性(AVAssetResourceLoadingDataRequest)和一个ContentInformationRequest属性(AVAssetResourceLoadingContentInformationRequest)。我仍然不明白为什么这是必要的或者它带来了什么好处,但是我在这里使用它们的方法确实有效。一些有前途的博客/资源似乎表明您必须对ContentInformationRequest进行处理,但我发现您可以简单地忽略ContentInformationRequest,事实上更频繁地干扰它只会破坏事情。
  3. 苹果建议将VTT文件分成小块,但是您无法在客户端执行此操作(苹果不允许此操作),但幸运的是,似乎您实际上不必这样做,这仅仅是个建议。

拦截请求

要拦截请求,您必须子类化/扩展AVAssetResourceLoaderDelegate,并关注ShouldWaitForLoadingOfRequestedResource方法。要使用该代理,请通过将AVPlayerItem交给AVPlayer来实例化您的AVPlayer,但将AVPlayerItem交给具有委托属性的AVUrlAsset,您将分配该委托。所有请求都将经过ShouldWaitForLoadingOfRequestedResource方法,因此所有业务都将在此处发生,除了一个阴险的复杂性-仅当请求以http/https之外的某些内容开头时,该方法才会调用。因此,我的建议是,在创建AVUrlAsset所使用的URL前面添加一个常量字符串,然后在请求传递到您的代理后将其删除-让我们称之为"CUSTOMSCHEME" 。这部分在线上的一些地方有描述,但如果您不知道必须这样做,它会非常令人沮丧,因为似乎根本没有任何事情发生。

拦截 - 类型A)重定向

好的,现在我们正在拦截请求,但您不想(或者不能)全部自己处理。你可以通过以下方式实现:

  1. 创建一个新的NSUrlRequest,指向经过修正的URL(删除之前的"CUSTOMSCHEME"部分),并将其设置为LoadingRequest的Redirect属性
  2. 使用同样的已经修正的URL创建一个新的NSHttpUrlResponse,并将其设置为LoadingRequest的Response属性,状态码为302
  3. 在LoadingRequest上调用FinishLoading
  4. 返回true

有了这些步骤,您可以添加断点等以调试和检查所有请求,但它们将正常进行,因此您不会破坏任何内容。然而,这种方法不仅适用于调试,对于几个请求来说,它也是一个必要的事情。

拦截 - B 类型)编辑/伪造响应

当一些请求到达时,您将希望发起一个自己的请求,以便对该请求的响应(稍作调整后)可以用来满足 LoadingRequest。因此,请执行以下操作:

  1. 创建一个 NSUrlSession,并在会话上调用 CreateDataTask 方法(使用已更正的 URL - 删除 "CUSTOMSCHEME")
  2. 在 DataTask 的回调之外,调用 DataTask 的 Resume 方法
  3. 返回 true
  4. 在 DataTask 回调中,您将获得数据,因此(在进行编辑后)可以使用该(已编辑)数据调用 LoadingRequest 的 DataRequest 属性上的 Respond,然后调用 LoadingRequest 的 FinishLoading

拦截 - 哪些请求需要哪种类型的处理

会有许多请求进来,有些需要重定向,有些需要返回制造/修改后的数据响应。以下是您将看到的请求的类型及其处理方法:

  1. 主播放列表的请求,但 DataRequest 的 RequestedLength 为 2 - 只需重定向(类型 A)
  2. 主播放列表的请求,但 DataRequest 的 RequestedLength 与(未编辑)主播放列表的长度匹配 - 进行自己的请求到主播放列表,以便您可以编辑它并返回编辑后的结果(类型 B)
  3. 主播放列表的请求,但 DataRequest 的 RequestedLength 非常大 - 做与之前相同的事情(类型 B)
  4. 将会有许多音频和视频片段的请求 - 所有这些请求都需要重定向(类型 A)
  5. 一旦您正确编辑了主播放列表(并选择了字幕),将会收到一个对于字幕播放列表的请求 - 编辑此请求以返回制造出的字幕播放列表(类型 B)

如何编辑播放列表 - 主播放列表

主播放列表很容易编辑。更改是两个方面:

  1. 每个视频资源都有自己的行,并且它们都需要告知字幕组(对于每一行以 #EXT-X-STREAM-INF 开头的行,在末尾添加 ,SUBTITLES="subs"
  2. 需要添加新行以表示每种字幕语言/类型,它们都属于具有自己 URL 的字幕组(因此,对于每种类型,添加类似于 #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",LANGUAGE="!!!yourLanguageHere!!!",NAME="!!!yourNameHere!!!",AUTOSELECT=YES,URI="!!!yourCustomUrlHere!!!" 的行

第二步中使用的Url(!!!yourCustomUrlHere!!!)需要由您进行检测,以便在请求时将制作好的字幕播放列表作为响应的一部分返回,因此请将其设置为独特的值。该Url还必须使用"CUSTOMSCHEME",以便到达委托对象。您还可以查看此流媒体示例,以查看清单应如何呈现:https://developer.apple.com/streaming/examples/basic-stream-osx-ios5.html(使用浏览器调试器嗅探网络流量进行查看)。

如何编辑播放列表-字幕播放列表

字幕播放列表略微复杂。您必须自己创建整个文件。我的方法是在DataTask回调函数中获取WebVtt文件,然后解析它以找到最后一个时间戳序列的末尾,将其转换为秒数并在大字符串的几个位置插入该值。同样,您可以使用上面提到的示例,并通过嗅探网络流量来查看真实的示例。因此,它看起来像这样:

#EXTM3U
#EXT-X-TARGETDURATION:!!!thatLengthIMentioned!!!
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:!!!thatLengthIMentioned!!!
!!!absoluteUrlToTheWebVttFileOnTheServer!!!
#EXT-X-ENDLIST

请注意,播放列表不会像苹果建议的那样对vtt文件进行分段,因为这无法在客户端完成(来源:https://developer.apple.com/forums/thread/113063?answerId=623328022#623328022)。此外,请注意,即使苹果的示例中要求在“EXTINF”行末尾加上逗号,但我也没有这样做,因为这似乎会破坏代码(https://developer.apple.com/videos/play/wwdc2012/512/)。
现在是实际代码:
public class CustomResourceLoaderDelegate : AVAssetResourceLoaderDelegate
{
    public const string LoaderInterceptionWorkaroundUrlPrefix = "CUSTOMSCHEME"; // a scheme other than http(s) needs to be used for AVUrlAsset's URL or ShouldWaitForLoadingOfRequestedResource will never be called
    private const string SubtitlePlaylistBoomerangUrlPrefix = LoaderInterceptionWorkaroundUrlPrefix + "SubtitlePlaylist";
    private const string SubtitleBoomerangUrlSuffix = "m3u8";
    private readonly NSUrlSession _session;
    private readonly List<SubtitleBundle> _subtitleBundles;

    public CustomResourceLoaderDelegate(IEnumerable<WorkoutSubtitleDto> subtitles)
    {
        _subtitleBundles = subtitles.Select(subtitle => new SubtitleBundle {SubtitleDto = subtitle}).ToList();
        _session = NSUrlSession.FromConfiguration(NSUrlSessionConfiguration.DefaultSessionConfiguration);
    }

    public override bool ShouldWaitForLoadingOfRequestedResource(AVAssetResourceLoader resourceLoader,
        AVAssetResourceLoadingRequest loadingRequest)
    {
        var requestString = loadingRequest.Request.Url.AbsoluteString;
        var dataRequest = loadingRequest.DataRequest;

        if (requestString.StartsWith(SubtitlePlaylistBoomerangUrlPrefix))
        {
            var uri = new Uri(requestString);
            var targetLanguage = uri.Host.Split(".").First();
            var targetSubtitle = _subtitleBundles.FirstOrDefault(s => s.SubtitleDto.Language == targetLanguage);

            Debug.WriteLine("### SUBTITLE PLAYLIST " + requestString);
            if (targetSubtitle == null)
            {
                loadingRequest.FinishLoadingWithError(new NSError());
                return true;
            }
            var subtitlePlaylistTask = _session.CreateDataTask(NSUrlRequest.FromUrl(NSUrl.FromString(targetSubtitle.SubtitleDto.CloudFileURL)),
                (data, response, error) =>
                {
                    if (error != null)
                    {
                        loadingRequest.FinishLoadingWithError(error);
                        return;
                    }
                    if (data == null || !data.Any())
                    {
                        loadingRequest.FinishLoadingWithError(new NSError());
                        return;
                    }
                    MakePlaylistAndFragments(targetSubtitle, Encoding.UTF8.GetString(data.ToArray()));

                    loadingRequest.DataRequest.Respond(NSData.FromString(targetSubtitle.Playlist));
                    loadingRequest.FinishLoading();
                });
            subtitlePlaylistTask.Resume();
            return true;
        }

        if (!requestString.ToLower().EndsWith(".ism/manifest(format=m3u8-aapl)") || // lots of fragment requests will come through, we're just going to fix their URL so they can proceed normally (getting bits of video and audio)
            (dataRequest != null && 
             dataRequest.RequestedOffset == 0 && // this catches the first (of 3) master playlist requests. the thing sending out these requests and handling the responses seems unable to be satisfied by our handling of this (just for the first request), so that first request is just let through. if you mess with request 1 the whole thing stops after sending request 2. although this means the first request doesn't get the same edited master playlist as the second or third, apparently that's fine.
             dataRequest.RequestedLength == 2 &&
             dataRequest.CurrentOffset == 0))
        {
            Debug.WriteLine("### REDIRECTING REQUEST " + requestString);
            var redirect = new NSUrlRequest(new NSUrl(requestString.Replace(LoaderInterceptionWorkaroundUrlPrefix, "")));
            loadingRequest.Redirect = redirect;
            var fakeResponse = new NSHttpUrlResponse(redirect.Url, 302, null, null);
            loadingRequest.Response = fakeResponse;
            loadingRequest.FinishLoading();
            return true;
        }

        var correctedRequest = new NSMutableUrlRequest(new NSUrl(requestString.Replace(LoaderInterceptionWorkaroundUrlPrefix, "")));
        if (dataRequest != null)
        {
            var headers = new NSMutableDictionary();
            foreach (var requestHeader in loadingRequest.Request.Headers)
            {
                headers.Add(requestHeader.Key, requestHeader.Value);
            }
            correctedRequest.Headers = headers;
        }

        var masterPlaylistTask = _session.CreateDataTask(correctedRequest, (data, response, error) =>
        {
            Debug.WriteLine("### REQUEST CARRIED OUT AND RESPONSE EDITED " + requestString);
            if (error == null)
            {
                var dataString = Encoding.UTF8.GetString(data.ToArray());
                var stringWithSubsAdded = AddSubs(dataString);

                dataRequest?.Respond(NSData.FromString(stringWithSubsAdded));

                loadingRequest.FinishLoading();
            }
            else
            {
                loadingRequest.FinishLoadingWithError(error);
            }
        });
        masterPlaylistTask.Resume();
        return true;
    }

    private string AddSubs(string dataString)
    {
        var tracks = dataString.Split("\r\n").ToList();
        for (var ii = 0; ii < tracks.Count; ii++)
        {
            if (tracks[ii].StartsWith("#EXT-X-STREAM-INF"))
            {
                tracks[ii] += ",SUBTITLES=\"subs\"";
            }
        }

        tracks.AddRange(_subtitleBundles.Select(subtitle => "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",LANGUAGE=\"" + subtitle.SubtitleDto.Language + "\",NAME=\"" + subtitle.SubtitleDto.Title + "\",AUTOSELECT=YES,URI=\"" + SubtitlePlaylistBoomerangUrlPrefix + "://" + subtitle.SubtitleDto.Language + "." + SubtitleBoomerangUrlSuffix + "\""));

        var finalPlaylist = string.Join("\r\n", tracks);
        return finalPlaylist;
    }

    private void MakePlaylistAndFragments(SubtitleBundle subtitle, string vtt)
    {
        var noWhitespaceVtt = vtt.Replace(" ", "").Replace("\n", "").Replace("\r", "");
        var arrowIndex = noWhitespaceVtt.LastIndexOf("-->");
        var afterArrow = noWhitespaceVtt.Substring(arrowIndex);
        var firstColon = afterArrow.IndexOf(":");
        var period = afterArrow.IndexOf(".");
        var timeString = afterArrow.Substring(firstColon - 2, period /*(+ 2 - 2)*/);
        var lastTime = (int)TimeSpan.Parse(timeString).TotalSeconds;

        var resultLines = new List<string>
        {
            "#EXTM3U",
            "#EXT-X-TARGETDURATION:" + lastTime,
            "#EXT-X-VERSION:3",
            "#EXT-X-MEDIA-SEQUENCE:0",
            "#EXT-X-PLAYLIST-TYPE:VOD",
            "#EXTINF:" + lastTime,
            subtitle.SubtitleDto.CloudFileURL,
            "#EXT-X-ENDLIST"
        };

        subtitle.Playlist = string.Join("\r\n", resultLines);
    }

    private class SubtitleBundle
    {
        public WorkoutSubtitleDto SubtitleDto { get; set; }
        public string Playlist { get; set; }
    }

    public class WorkoutSubtitleDto
    {
        public int WorkoutID { get; set; }
        public string Language { get; set; }
        public string Title { get; set; }
        public string CloudFileURL { get; set; }
    }
}

我只是想知道,AirPlay 对你有用吗?我得到了几乎相同的解决方案,在 iPhone、iPad 上都可以工作,但 AirPlay 就是不行... - olejnjak

2
如果使用流媒体服务,您可以编辑流媒体清单并上传其他文件,其中包含已编码的媒体,则通过一些手动操作(可以脚本化),您可以按照iOS所期望的方式将字幕放入清单中。我能够在Azure Media Services上使其正常工作,尽管有点hacky。
由于Azure Media Services(以下简称AMS)流式传输端点会即时创建流式传输清单(on the fly),因此我无法仅向文件添加必要的更改。相反,我基于AMS生成的播放列表创建了一个新的主播放列表。@SomeXamarinDude在他的答案中解释了需要进行的更改,但为了完整起见,我将包括一个示例。
假设AMS从具有以下URL的流式传输端点生成了主播放列表:
https://mediaservicename-use2.streaming.media.azure.net/d36754c2-c8cf-4f0f-b73f-dafd21fff50f/YOUR-ENCODED-ASSET.ism/manifest\(format\=m3u8-aapl\)

看起来像这样:
#EXTM3U
#EXT-X-VERSION:4
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="aac_eng_2_128079_2_1",LANGUAGE="eng",DEFAULT=YES,AUTOSELECT=YES,URI="QualityLevels(128079)/Manifest(aac_eng_2_128079_2_1,format=m3u8-aapl)"
#EXT-X-STREAM-INF:BANDWIDTH=623543,RESOLUTION=320x180,CODECS="avc1.640015,mp4a.40.2",AUDIO="audio"
QualityLevels(466074)/Manifest(video,format=m3u8-aapl)
#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=623543,RESOLUTION=320x180,CODECS="avc1.640015",URI="QualityLevels(466074)/Manifest(video,format=m3u8-aapl,type=keyframes)"
#EXT-X-STREAM-INF:BANDWIDTH=976825,RESOLUTION=480x270,CODECS="avc1.64001e,mp4a.40.2",AUDIO="audio"
QualityLevels(811751)/Manifest(video,format=m3u8-aapl)
...

然后,手动创建的播放列表(我将其命名为manually-created-playlist.m3u8)需要看起来像这样:
#EXTM3U
#EXT-X-VERSION:4
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",LANGUAGE="en",AUTOSELECT=YES,URI="https://mediaservicename-use2.streaming.media.azure.net/d36754c2-c8cf-4f0f-b73f-dafd21fff50f/subtitle-playlist.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="aac_eng_2_128079_2_1",LANGUAGE="eng",DEFAULT=YES,AUTOSELECT=YES,URI="YOUR-ENCODED-ASSET.ism/QualityLevels(128079)/Manifest(aac_eng_2_128079_2_1,format=m3u8-aapl)"
#EXT-X-STREAM-INF:SUBTITLES="subs",BANDWIDTH=623543,RESOLUTION=320x180,CODECS="avc1.640015,mp4a.40.2",AUDIO="audio"
YOUR-ENCODED-ASSET.ism/QualityLevels(466074)/Manifest(video,format=m3u8-aapl)
#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=623543,RESOLUTION=320x180,CODECS="avc1.640015",URI="YOUR-ENCODED-ASSET.ism/QualityLevels(466074)/Manifest(video,format=m3u8-aapl,type=keyframes)"
#EXT-X-STREAM-INF:SUBTITLES="subs",BANDWIDTH=976825,RESOLUTION=480x270,CODECS="avc1.64001e,mp4a.40.2",AUDIO="audio"
YOUR-ENCODED-ASSET.ism/QualityLevels(811751)/Manifest(video,format=m3u8-aapl)
...

请注意,我必须对各种比特率播放列表进行路径更改。
然后,此手动播放列表需要上传到包含其余编码媒体资产的相同 Azure 存储容器中。
我还必须创建并上传一个名为 subtitle-playlist.m3u8 和一个 transcript.vtt 的文件到相同的 Azure 存储容器。我的字幕播放列表如下所示:
#EXTM3U
#EXT-X-TARGETDURATION:61
#EXT-X-ALLOW-CACHE:YES
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:1
#EXTINF:61.061000
https://mediaservicename-use2.streaming.media.azure.net/d36754c2-c8cf-4f0f-b73f-dafd21fff50f/transcript.vtt
#EXT-X-ENDLIST

请注意,某些字幕播放列表值取决于WebVTT文件的长度。
此时,您应该能够将HLS播放器指向以下URL,并能够启用闭幕式字幕:
https://mediaservicename-use2.streaming.media.azure.net/d36754c2-c8cf-4f0f-b73f-dafd21fff50f/manually-created-master-playlist.m3u8

我希望这能帮助到某个人。显然,AMS方面正在制定一个用于解决此问题的工单
感谢@SomeXamarinDude提供你的答案;如果不是你所做的所有工作,我可能会完全迷失在这个问题上。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接