前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >酷我音乐iOS小组件适配开发实践

酷我音乐iOS小组件适配开发实践

作者头像
QQ音乐技术团队
发布2023-12-15 08:40:39
4020
发布2023-12-15 08:40:39
举报

前言

本文来自TME腾讯音乐娱乐-酷我音乐团队,迈腾大队长投稿,本文仅作为个人学习记录使用,也欢迎在许可协议范围内转载或使用,请尊重版权并且保留原文链接,谢谢您的理解合作. 如果您觉得本文章对您能有帮助,您可以使用关注此公众号,感谢支持.

背景介绍

随着iOS17逐渐普及,一些App的功能逐渐向周围延伸.其中包含对手机端以及苹果各种平台的适配工作,本文讲述的主要内容是在iOS17上的Extension小组件技术开发实践.

酷我音乐是TME(腾讯音乐娱乐)集团中的一个业务线,在app端的功能开发 稍逊色于导师Q音, TME集团主要包含很多App,例如大家常用的QQ音乐、酷狗音乐、酷我音乐、全民K歌、懒人听书...等等.

iOS小组件从iOS14~iOS16一直都有,只不过是有很多不常用的功能很少被大众发现,比如之前的锁屏小组件, 为了解决功能单一,提高交互性和用户体验.从iOS17开始我们着力开发新功能特性.于是就有个这篇文章

这篇文章笔者可以跟大家保证,绝对不是照抄wwdc或其它开发相关小组件的基础教程,这篇文章遇到的问题只有深入开发才会遇到,最近iOS领域这一段时间已经变得不活跃,没有什么高阶版本相关开发文章,希望借此机会勉励一下大家,我们这个行业是一个很伟大的行业,只有优秀的人、出色的人、肯努力的人才能出头, 希望通过我们共同的努力把这个行业推上一个新的高度.

这篇文章非常感谢来自QQ音乐团队、酷狗音乐团队的顶力支持,很多内容都是这两团队的开发兄弟提供的方法和思路甚至内部共享的技术内容.

小组件开发遇到的问题

  • iOS17适配容器视图问题
  • swiftUI中支持的Button 在Extension的widget中无法正常使用
  • 使用AppIntent Button 后的 widget和host app进程间通讯问题
  • 如何让开发中的Intent不在指令app中显示
  • widgetURL和Link跳转app问题
  • 如何实现歌词动画
  • 刷新频限问题
  • widgetBundle超出10个的数量限制问题
  • 如何决定何时拉端或不拉端问题

开发前的科普

  • 我们的主app 叫 host app(宿主app) 可以这么理解
  • 我们开发的小组件叫widget(挂件,小部件) 我们简称 "小组件"吧,其实就是Extension的一种.
  • Extension 这个就是app扩展的target,我简称它为扩展应用
  • 开发之前的基本操作 是创建新的target这自然不用多说,然后放在同一个包名下的group,这大家应该都懂,不了解的先创建一个demo就知道了.
  • 开发使用swiftUI框架,没测试过是否兼容Objective-C的内容.
  • 如果通过点击widget中的动作打开app这个过程我们通俗点叫它拉端 就是点击widget后会产生直接把app调用起来的效果,有些开发者喜欢叫它呼起app.都一个意思,以下简称拉端操作
  • 我们之前都了解在Extension中点击widget中的视图按钮 会调用类似openURL的scheme方式打开app来达到从widget进程到app进程之间互相传递事件和参数的目的,经过探索WWDC视频发现,这种方式苹果成为 deep link. 以下简称 deep link.
  • 开发之前要了解的是,小组件不是小app,不能做太多的对数据的增删改查的类似的复杂操作,经过我们探索发现最多能当做UI视图使用,也能发网络请求,你把它当成UIView就好,并且还仅支持单向数据流(基于TimeLine更新数据以供swiftUI的视图展示和交互使用),这种数据驱动视图符合swiftUI的声明式编程范式(跟我们用的OOP面向对象有较大差距)

这篇文章不能算教程,只能是算开发中遇到的过程记录,如果需要查看开发教程的话我建议去B站看一下相关开发视频,或者参考一下如何使你的小组件栩栩如生

我肤浅的认为,做一件事之前我们首先要达成共识,从实际问题出发,求真务实、实事求是. 上面的科普内容大家可以粗略瞄一下不需要大家都深入理解,主要是便于下面的问题驱动和技术的解决方案使用.

从遇到问题和解决问题开始

当我们第一次新建widget的target 然后打开后, xcode会自动生成相关不同大小的小组件,也伴随着它自动生成的相关代码.如果第一次不了解小组件工作原理可以参考官方的WWDC https://developer.apple.com/videos/wwdc2023/?q=widget视频看一下 .我简单在这里介绍一下就好

小组件工作原理是利用TimeLine中的不同时刻更新小组件,更新的内容以实体(Entry模型)的方式,具体TimeLine更新widget的具体内容可以参考https://developer.apple.com/videos/play/wwdc2020/10035/?time=22这个视频会给大家讲清楚Timeline是怎么回事,为啥要用TimeLine更新小组件,其实大家想知道的所有关于小组件的基础知识都在上边视频里.

TimeLineEntry

Entry大家可以理解 一个类似Person的结构体模型,苹果喜欢称为Entry就类似我们MVC中的model,它本质结构就是model,model就是Entry.此处接受各种反驳,只是我观察它很长时间它干的事就是model.

以上示例代码:

代码语言:javascript
复制
struct GameStatusEntry: TimelineEntry {
    var date: Date
    var gameStatus: String
}

基于以上的基础知识就建立起来了一个用TimeLine刷新更新TimeLineEntry到widget的链路内容和基本元素.

酷我这面使用的是AppIntent的小组件模版代码.

这种小组件模版配置代码有3种:

  • 1.iOS14之后静态配置模版代码StaticConfiguration
  • 2.iOS14之后的意图配置模版代码IntentConfiguration
  • 3.iOS17新版意图模版代码AppIntentConfiguration 这里的静态配置模版代码是指创建小组件后自动生成的 没有太多数据更新,用于UI展示点击交互后(拉端 跳app)的模版代码. IntentConfiguration和静态的相反增强交互和Intent意图(快捷指令app里面的每一个app提供的动作可以称为意图),这个貌似需要自己手动负责设置之前工程已有的Intent配置,比较麻烦,懂得同学可以忽略. 以上两种都支持iOS17以下(不包含iOS17)系统,可以理解为老代码可向下兼容 除了以上两种以外iOS17新增了优化版本的意图模版代码.酷我这边使用的方式比较激进,直接上第三种的iOS17新版提供的模版代码(缺点是桌面小组件不兼容iOS16,优点是iOS17的特性直接使用没有历史包袱问题).至于前两种方案,笔者没有深度实践过所以对此不做评判和过多的介绍.

以下是静态的代码模版:

代码语言:javascript
复制
@available(iOS 14.0, macOS 11.0, watchOS 9.0, *)
@available(tvOS, unavailable)
public struct StaticConfiguration<Content> : WidgetConfiguration where Content : View {

    /// The content and behavior of this widget.
    public var body: some WidgetConfiguration { get }

    /// The type of widget configuration representing the body of
    /// this configuration.
    ///
    /// When you create a custom widget, Swift infers this type from your
    /// implementation of the required `body` property.
    public typealias Body = some WidgetConfiguration
}

以下是之前组件用的最多的意图原始模版代码:

代码语言:javascript
复制
@available(iOS 14.0, macOS 11.0, watchOS 9.0, *)
@available(tvOS, unavailable)
public struct IntentConfiguration<Intent, Content> : WidgetConfiguration where Intent : INIntent, Content : View {

    /// The content and behavior of this widget.
    public var body: some WidgetConfiguration { get }

    /// The type of widget configuration representing the body of
    /// this configuration.
    ///
    /// When you create a custom widget, Swift infers this type from your
    /// implementation of the required `body` property.
    public typealias Body = some WidgetConfiguration
}

以下是iOS17新增的组件模版代码示意:

代码语言:javascript
复制
@available(iOS 17.0, macOS 14.0, watchOS 10.0, *)
@available(tvOS, unavailable)
public struct AppIntentConfiguration<Intent, Content> : WidgetConfiguration where Intent : WidgetConfigurationIntent, Content : View {

    /// The content and behavior of this widget.
    public var body: some WidgetConfiguration { get }

    /// The type of widget configuration representing the body of
    /// this configuration.
    ///
    /// When you create a custom widget, Swift infers this type from your
    /// implementation of the required `body` property.
    public typealias Body = some WidgetConfiguration
}

注意:开发中我们大部分都会使用这其中的某一种.这种配置模版将会决定我们如何开发小组件,代码其实大同小异,包含截图、placehoder、真正显示的内容或场景.

如果同学您的团队项目做技术选型的时候可以充分考虑各种方案,因地制宜.使用哪种方式都可以,适合自己团队的才是最优解.

1.iOS17小组件容器背景适配问题

小组件的容器适配首先第一个就是. containerBackground容器适配问题, 如下图:

这是因为Apple的iOS17做了跟多和桌面小组件的优化,iOS17开始苹果统一了自家的平台包括iOS、iPadOS、macOS、WatchOS、TVOS,苹果想让这个东西被系统的模式着色(就是系统会觉得这个小组件要不要边框,显示什么颜色,比如Apple Watch屏幕不够大它就要适配这个),所以需要统一使用一种通用的的方式用以适配各种平台的桌面,以小组件的形式为单位的形式存在,这就需要我们做一些适配工作.

代码语言:javascript
复制
KWTestLargeWidgetView(entry: entry)
    .containerBackground(.fill.tertiary, for: .widget)

注意: 这个api使用完后建议验证一下iOS17之前的系统影不影响,经过我们测试发现需要在bundle入口直接隔离iOS17组件和iOS17以下组件才不影响之前的锁屏小组件.如果我们使用可用性检测(if #available(iOSApplicationExtension 17.0, *)) 之类的代码是不解决实际问题的.必须在bundle入口的地方加上可用性检测代码才能根本性解决此问题.

下方代码是酷我音乐这边的写法,仅供参考:

代码语言:javascript
复制
import WidgetKit
import SwiftUI

@available(iOSApplicationExtension 16.0, *)
@main
struct WidgetLauncher {
    // Combine widgets in this way to prevent crashes.
    static func main() {
        if #available(iOSApplicationExtension 17.0, *) {
            WidgetsBundle17.main()
        } else {
            WidgetsBundle16.main()
        }
    }
}

struct WidgetsBundle16: WidgetBundle {
    var body: some Widget {
        KWxxxxWidget1()
        ...
    }
}

@available(iOSApplicationExtension 17.0, *)
struct WidgetsBundle17: WidgetBundle {
    var body: some Widget {
        KWxxxxWidget1()
        ...
    }
}

随着适配背景容器,下面的一个边距也成了要适配的重点工作.先看一下下方截图,适配前后的代码变化.

.contentMarginsDisabled() 安全区域边界适配.

代码语言:javascript
复制
var body: some WidgetConfiguration {
    AppIntentConfiguration(kind: kind, intent: KWAppWidgetTestConfigurationIntent.self, provider: Provider()) { entry in
        KWTestLargeWidgetView(entry: entry)
            .containerBackground(.fill.tertiary, for: .widget) //适配容器背景
    }
    .contentMarginsDisabled() //https://developer.apple.com/videos/play/wwdc2023/10027/ 去掉安全区域边界
    .configurationDisplayName("复古磁带机")
    .description("让音乐以经典形态呈现")
}

如果要适配就去掉.contentMarginsDisabled(),然后再使用的视图struct中声明的环境变量中的边距

代码语言:javascript
复制
@Environment(\.widgetContentMargins) var margins: EdgeInsets //如果适配边界请使用这个

再然后需要处理边距的View上 加上.padding(-margins),剩下的工作就是适配边距了.

要适配这个主要的原因是 有些apple watch的屏幕尺寸不一样,苹果为了留够边缘解决各种尺寸组件大小在不同屏幕上的显示的问题,做了一个内容边距设置,也主要是为了实现屏幕的组件的色彩染色(比如我们的组件是黄色,那苹果染色染成啥颜色就是啥颜色,这主要是和苹果系统的深浅模式适配有关)

因为酷我这边基本不需要做啥染色,直接上图,所以也不用做额外的适配工作,直接把这个边距关闭掉.(我们不推荐这样实现,因为这样做可能一个组件只能适配iOS,却不能适配其它 例如watchOS、等系统,就造成兼容性下降等问题.)

2.SwiftUI中支持的Button 在widget中无法正常使用

在小组件里面加一个按钮经常出现边缘太大,样式填充问题(红线是笔者截图加上去的请忽略)

浅蓝色透明度的填充部分,需要设置按钮的显示样式才能去掉,默认就是上图这种方式有填充.

代码语言:javascript
复制
Button(action: {

}, label: {
    Image("123")
        .frame(minWidth: 90, minHeight:90)
        .aspectRatio(contentMode: .fill)
        .tint(.clear)
        .padding(0)
})
//.buttonStyle(BorderlessButtonStyle()) //打开这行代码改成不需要边框样式按钮

.buttonStyle(BorderlessButtonStyle())打开这行代码改成不需要填充样式按钮

还有一种情况 按钮有可能不支持就会显示如下:

这种被禁止的icon,一开始开发的时候使用了小组件不支持的按钮.

经过上述操作后,我们只能给小组件加一个按钮,并不能实现点击按钮触发事件,当我们点击button的时候直接拉端,根本不给我们处理这个按钮的点击机会.

这是因为小组件不支持常规按钮. 可以参考这个链接找到答案 (Bring widgets to life) https://developer.apple.com/videos/play/wwdc2023/10028/?time=735

重点来了, 小组件仅支持 带有AppIntent意图的按钮和Toggle (cell 自带switch开关)控件.

啥是AppIntent?

可以简单理解为 可以通过siri语音控制 的指令 放在手机指令app中用于提供类似脚本一样的命令,这个在之前的框架中是在AppIntents库中, iOS17之前的app都是手动配置各种选项来开发类似指令的功能,不但麻烦,Xcode15以后这玩意还提供自动转换到新的框架中的方法.总之 可以理解为我们点击按钮触发的是系统的Intent意图命令就行了,后续看我们怎么处理.

这里拿酷我音乐里面的收藏按钮举个例子

代码语言:javascript
复制
Button(intent: KWAppWidgetConfigurationLikeIntent(info: itemInfo!)) {
    Image(itemInfo!.didCollected ? "kw_widget_tape_large_like" : "kw_widget_tape_large_unlike")
        .resizable()
        .aspectRatio(contentMode: .fill)
        .frame(minWidth: itemSize, maxWidth: .infinity, minHeight:itemSize, maxHeight:.infinity)
}
.frame(width: itemSize, height: itemSize)
.tint(.clear)
.padding(.trailing, space)
.buttonStyle(BorderlessButtonStyle())

KWAppWidgetConfigurationLikeIntent的定义如下:

代码语言:javascript
复制
struct KWAppWidgetConfigurationLikeIntent: AudioPlaybackIntent {
    static var title: LocalizedStringResource = "收藏或取消收藏当前歌曲"
    static var description = IntentDescription("酷我音乐")
    var info: KWWidgetItemInfo
    init() {
        self.info = KWWidgetItemInfo.defaultInfo()
    }
    init(info: KWWidgetItemInfo) {
        self.info = info
    }
    func perform() async throws -> some IntentResult {
        try await KWWidgetAppIntentHandle.handleWidgetAppIntentFavorite() //注意这行代码
        return .result()
    }
}

这样当用户点击带有意图的按钮就会触发Intent中的perfrom()函数.开发者可以在这处理点击的按钮的操作逻辑.

KWAppWidgetConfigurationLikeIntent可以继承自WidgetConfigurationIntent或者AudioPlaybackIntent这些都是我们使用的WidgertKit中支持的,区别的和不同点,苹果文档有说明,实测发现没有太多不一致.由于时间紧迫没有仔细研究,欢迎读者看到后感兴趣可以在评论区讨论,自己动手实践一下.

3.采用Intent Button 后 widget的Extension和host app如何实现进程间通讯问题?

讲这个之前我们要对Extension和app有个简单的理解,众所周知,我们的app是一个进程,它的扩展(Extension)是另一个进程.我们的小组件整体target就是一个Extension.

在笔者肤浅的认知中认为,实现Extension和app通讯有几种方式

  • NSUserDefault 共享数据
  • openURL类似的widgetURL方式通过专用的scheme协议跳转app传递参数

这两种有使用限制和场景约束,NSUserDefault只能实现数据互通,并不能实现事件传递. openURL需要打开app拉端,如果不打开app是不能实现参数传递的.

看到此处请原谅我肤浅的认知.

今天我们来认识和解锁一种新的方式

  • AppIntent也可以实现在Extension和app之间传递事件,不用拉端.实现进程间通讯

根据上述问题2中的代码我们点击一个Button会自动调用KWAppWidgetConfigurationLikeIntent中的perform()函数, 可以看到 我们调用了如下代码

代码语言:javascript
复制
KWWidgetAppIntentHandle.handleWidgetAppIntentFavorite()
方式1 AppIntent

这种方式有点反人类,绝对颠覆你对苹果SDK的认知.

AudioPlaybackIntent最终还是继承自AppIntent类

这是酷我点击收藏的时候调用的AppIntent,它归属于小组件的widget target的extension中.

按照我们之前的对iOS开发的理解,同一份代码只需要勾选相应的target,基本就能共用,然而第一次尝试我也是这样想的,一份代码根本没有必要重复写两次,并且还放在不同的target里面.

如果你这样想的,首先你的思路没有问题,这是开发的正常思路,我第一次尝试这样做的时候失败了.因为虽然是同一份代码,但是它的进程空间是放在不同的进程中去执行的,根本无法实现我们想让Button点击直接调到host app里面.

于是翻看WWDC视频,苹果很隐晦的说 AppIntent苹果内部有静态元数据提取.

什么是静态元数据提取?

苹果对待AppIntent 实际是把内部的点击事件和信息抽取到了指令App中,这样方便后续的自动化操作控制手机上的各种app,但是WWDC中的视频演示时把AppIntent放在了Framework中,也就是它把这玩意打进了静态库里面. 然后让app和extension共同依赖,虽然苹果这个操作我没太看懂,就这么几行代码也打进库里是为了啥.

回到我们刚才的主题, 我们总不能为这几行代码打个静态库吧!

经过各种猜测和测试代码,我写了一份一模一样的代码放在了 主工程的target下

起名叫KWAppWidgetConfigurationLikeIntent 跟extension的代码一模一样 复制粘贴 ,然后

代码语言:javascript
复制
@available(iOSApplicationExtension 17.0, *)
struct KWAppWidgetConfigurationLikeIntent: AudioPlaybackIntent {
    static var title: LocalizedStringResource = "收藏或取消收藏当前歌曲"
    static var description = IntentDescription("酷我音乐")

    func perform() async throws -> some IntentResult {
          //... 这里直接调用收藏代码  
        return .result()
    }
}

这种思路来自于一起探索此功能的同事,我俩一起搞事情测出来的,如果两份同样名称的代码编译不过可以尝试给文件改个名字,但是不要改KWAppWidgetConfigurationLikeIntent.

经过上述尝试居然成功了!

啥?

颠覆你的认知吧! 同样的代码 只是所属的target不一样,它居然生效了,怎么做到的

我们甚至尝试 widget的Extension中写一个AppIntent壳代码,App内写具体实现,点击小组件的按钮直接调用到了App,这不就实现了进程间通讯吗!

经过我们测试

  • 两份同样的AppIntent代码 放在不同的target里面 可行
  • AppIntent不能传递参数.
  • 目前没有发现什么弊端

这第一次反人类的操作跟小伙伴说,小伙伴们都一致的认为这是苹果的bug, 我认为不管是不是bug,它这么做好使.反过来测试就是两个进程空间的内容根本无法实现代码共享,完全得益于苹果的静态元数据提取.

以上就是酷我这边实现的进程间的通讯方式,选择哪种看各位喜好,我还是坚持使用CFNotificationCenter可靠稳定,不知道苹果哪天不开心把这个给干掉的话就完蛋了.到时候再回归AppIntent,至少是一种可行的路线.做个备选.

4.如何让开发中的Intent不在指令app中显示

我们开发小组件肯定不可能用一种Intent,得有很多种按钮触发不同事件,比如播放暂停、下一曲、收藏、等等.由于苹果的静态元数据提取会把我们的Intent抽取到指令app中,为了不在指令app中显示我们需要这样做:

代码语言:javascript
复制
@available(iOSApplicationExtension 17.0, *)
struct KWAppWidgetConfigurationIntent: WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "酷我音乐"
    static var description = IntentDescription("酷我音乐小组件.")
    static var openAppWhenRun: Bool = true  //app启动 默认拉端
    static var isDiscoverable: Bool = false //不在指令app中显示
    func perform() async throws -> some IntentResult {
        return .result()
    }   
}

复写isDiscoverable = false

这样就不在指令app中显示了.也有缺点哈,缺点就是你的意图Intent指令受限制

代码语言:javascript
复制
/// Determines whether this App Intent is discoverable by system features, such as Shortcuts and Spotlight.
/// App Intents must be discoverable to be used with App Shortcuts. App Intents that are not discoverable
/// can be used only when directly specified by the developer, such as when tied to a button in a SwiftUI app or
/// a Widget. `true` by default.
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
static var isDiscoverable: Bool { get }

如果设置false 将会无法使用 ShortcutsSpotlight使用此意图.只能用于一个带有Intent的按钮.

5.widgetURL和Link跳转app问题

当我们点击某些View的时候需要跳转host app,在小组件的Extension中有一个apiwidgetURL

先来看一下示例代码:

代码语言:javascript
复制
HStack(alignment: .bottom) {
    Image(itemInfo!.didCollected ? "kw_widget_absorption_color_like" : "kw_widget_absorption_color_unlike")
        .resizable()
        .aspectRatio(contentMode: .fit)
        .frame(minWidth: itemSize.width, maxWidth: .infinity, minHeight:itemSize.height, maxHeight:.infinity)
        .widgetURL(URL(string: "sunyazhou://collectOrNot"))
        .border(.red)
    Image(itemInfo!.isPlay ? "kw_widget_absorption_color_play" : "kw_widget_absorption_color_pause")
        .resizable()
        .aspectRatio(contentMode: .fit)
        .frame(minWidth: itemSize.width, maxWidth: .infinity, minHeight:itemSize.height, maxHeight:.infinity)
        .widgetURL(URL(string: "sunyazhou://playOrPause"))
        .border(.cyan)
    Image("kw_widget_absorption_color_next")
        .resizable()
        .aspectRatio(contentMode: .fit)
        .frame(minWidth: itemSize.width, maxWidth: .infinity, minHeight:itemSize.height, maxHeight:.infinity)
        .widgetURL(URL(string: "sunyazhou://playNext"))
        .border(.blue)
}

最终的结果无论我点击的是哪个它都会执行sunyazhou://playNext,使用这个请注意:

代码语言:javascript
复制
@available(iOS 14.0, macOS 11.0, watchOS 9.0, *)
@available(tvOS, unavailable)
extension View {

    /// Sets the URL to open in the containing app when the user clicks the widget.
    /// - Parameter url: The URL to open in the containing app.
    /// - Returns: A view that opens the specified URL when the user clicks
    ///   the widget.
    ///
这行    /// Widgets support one `widgetURL` modifier in their view hierarchy.
这行    /// If multiple views have `widgetURL` modifiers, the behavior is
这行    /// undefined.
    public func widgetURL(_ url: URL?) -> some View

}

它明确标识如果并排添加多个widgetURL的话,这种行为是未定义不确定的,它默认会以最后一个添加的为准.好坑呀! 真不懂苹果的小组件开发到底能做啥,这不让那不让,跳转还这么多坑.

解决办法

代码语言:javascript
复制
HStack(alignment: .bottom) {
    Link(destination: URL(string: "sunyazhou://collectOrNot")!) {
        Image(itemInfo!.didCollected ? "kw_widget_absorption_color_like" : "kw_widget_absorption_color_unlike")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(minWidth: itemSize.width, maxWidth: .infinity, minHeight:itemSize.height, maxHeight:.infinity)
            .border(.red)
    }
    Link(destination: URL(string: "sunyazhou://playOrPause")!) {
        Image(itemInfo!.isPlay ? "kw_widget_absorption_color_play" : "kw_widget_absorption_color_pause")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(minWidth: itemSize.width, maxWidth: .infinity, minHeight:itemSize.height, maxHeight:.infinity)
            .border(.cyan)
    }
    Link(destination: URL(string: "sunyazhou://playNext")!) {
        Image("kw_widget_absorption_color_next")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(minWidth: itemSize.width, maxWidth: .infinity, minHeight:itemSize.height, maxHeight:.infinity)
            .border(.blue)
    }
}

注意: Link仅支持widget的Families中的systemMediumsystemLarge,不支持systemSmall.

使用的时候请注意.systemSmall样式的小组件.

6.如何实现歌词动画

先来看下酷我这边实现的效果.

双行歌词动效实现非常简单全部基于SwiftUI中系统提供的API实现.

代码语言:javascript
复制
Text(entry.info.currLine) //首行高亮
    .font(.system(size: 20).bold())
    .foregroundColor(.white)
    .lineLimit(2)
    .truncationMode(.tail)
    .id(entry.info.currLine)
    .transition(.push(from: .bottom)) //先设置从下向上推的转场动效
    .animation(.easeInOut(duration: 0.6), value: entry.info.currLine) //配合动画修改器

entry.info.currLine 提供歌词的字符串变量

这里实现比较简单

  • 1.先设置从下向上的转场
  • 2.再设置动效配合参数做渐入淡出的缓动差时器,配合内容以及动画时长即可实现.

注意刷新频限问题, 歌词都是逐行刷新,如果过快将会受到系统频限限制无法刷新,请继续往下看解决办法.

实现比较简单,第二行无非就是改变颜色和位置而已.这里就不过多介绍了.

小结

开发双行歌词的时候,一开始是比较痛苦的,主要是自己对swiftUI动画的积累是不够的,所以做起来比较慢.当熟悉了之后渐渐地轻车熟路.

7.刷新频限问题

iOS小组件是不允许我们频繁实时刷新的,它有频率限制,如果太频繁的刷新数据驱动UI很容易造成被系统忽略,UI上的表象就是啥也没变.所以各位一定注意

当我们点击按钮触发AppIntent调用的时候,系统会立即刷新一次本次点击的小组件,如果点击过快第二次将会不生效.

解决办法就是响应后延时调用

代码语言:javascript
复制
@objc
class func reloadAllSystemWidgets() {
    NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(delayReloadAllSystemWidgets), object: nil)
    self.perform(#selector(delayReloadAllSystemWidgets), with: nil, afterDelay: 0.2)
}

/// 根据kind标识刷新控件
@objc
private class func delayReloadAllSystemWidgets() {
    WidgetCenter.shared.reloadTimelines(ofKind: "com.sunyazhou.widget1")
    ....
}

对外提供统一调用刷新入口,调用的时候采取,cancelPrevious调用然后延迟调用刷新组件的函数方法. 这里的代码是在主App中实现,因为作为数据提供方,主app有对小组件频控的能力和责任.

刷新可以使用WidgetCenter.shared.reloadTimeline根据kind标识刷新指定的小组件,也可以全部刷新小组件.

这里有个坑, 是同事发现的,如果刷新全部小组件,执行的动画就有一点点抖,如果单个刷新就没有问题.

目前确实没有太好的解决方式,如果后续有进展会继续发布相关文章,这里全当记录一下TODO.

8.widgetBundle超出10个的数量限制问题

这个主要是SwiftUI不支持超过单层堆叠视图超过10层,当我们app开发比较多的时候各式各样的组件会层出不穷,组件多了最好分门别类规划好用途和类别,以便后续维护.

这是系统的WidgetBundleBuilder的api

代码语言:javascript
复制
@available(iOS 14.0, macOS 11.0, watchOS 9.0, *)
@available(tvOS, unavailable)
extension WidgetBundleBuilder {

    public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> some Widget where C0 : Widget, C1 : Widget, C2 : Widget, C3 : Widget, C4 : Widget, C5 : Widget, C6 : Widget, C7 : Widget, C8 : Widget, C9 : Widget

}

使用的时候如下示例代码:

代码语言:javascript
复制
import WidgetKit
import SwiftUI

@main
struct KWWidgetBundle: WidgetBundle {
    var body: some Widget {
        DesktopComponent()
    }

    func KWWidget() -> some Widget {
        if #available(iOS 17.0, *) {
            return WidgetBundleBuilder.buildBlock(WidgetsBundleA().body,
                                                  WidgetsBundleB().body,
                                                  WidgetsBundleC().body) //可以最多使用10个
            //...
        }
        if #available(iOS 16.1, *) {
            return WidgetBundleBuilder.buildBlock(WidgetsBundleD().body,
                                                  WidgetsBundleE().body,
                                                  WidgetsBundleF().body) //可以最多使用10个
            //...
        }
    }
}

@available(iOS 17.0, *)
struct WidgetsBundleA: WidgetBundle {
    var body: some Widget {
        KWPlayMusicWidget1()
        KWPlayMusicWidget2()
        //...
        //不能超过10个
    }
}

@available(iOS 16.1, *)
struct KWPlayMusicWidget1: Widget {
    let kind: String = "sunyazhou.com1"

    var body: some WidgetConfiguration {
        //...这里写组件内部的堆叠视图
    }
}


struct KWPlayMusicWidget2: Widget {
    let kind: String = "sunyazhou.com2"

    var body: some WidgetConfiguration {
        //...这里写组件内部的堆叠视图
    }
}

通过此种方式解决小组件的数量限制.笔者把这种现象称为跳板原理

跳板原理

假设我家里有一个路由器,从路由器接出来的端口有4个,如果纯有线的方式最多能接4台计算机,为了让更多台设备有线接入我们要么引入更多路由器或者交换机插到原始路由的端口作为更多端口的扩展设备接口,那这个交换机或者路由器就是跳板,通过跳板,我们就实现了更多设备的接入这种现象我称为跳板原理.

上述的小组件本质就是这个原理,为了更多小组件必须用Bundle来管理,通过不同的Bundle来区分和显示更多小组件.

10.如何决定什么时候拉端什么时候不拉端

在开发小组件的时候我们通过AppIntent来触发决定事件触发,有些时候我们需要check按需拉起app,eg:用户没有登录时点击收藏,这时候需要直接拉起app,让用户登录.可是点击的AppIntent已经处理点击事件.

AppIntent中有个成员变量

代码语言:javascript
复制
/// If the app is not in the foreground, consider the intent anyways.
static var openAppWhenRun: Bool { get }

然而只能设置一次后不能修改了,static变量,所以我们想区分是否拉端需要使用两个AppIntent,假设AB. A负责不拉端,B负责拉端.

什么时候决定拉与不拉,通过NSUserDefault给widget的Extension提供数据的时候写好相关字段.

例如appDidLaunch,这种字段标识是否app已经启动来决定是否拉端.

这个写法虽然能解决问题,但很遗憾目前为止没有找到比这个更好的最优解,如果你有什么好的idea可以写在评论区.

总结

本次小组件开发没有为大家准备demo还请大家原谅,这些零散的内容 都列出来几乎重新开发一遍小组件,为了帮助解决开发中大家的遇到的问题,把一些关键的内容都列举出来了, 之前在笔者的博客中是遵守有洲哥的文章必须有demo原则.这篇文章仅仅是列举一下开发中的问题,希望大家开发一遍自然就会了解技术细节,剩下的留给大家去探索和实践.

至于大家非常关心的问题

1.除了以上讲述的进程间通讯还有其它的通讯方式为什么没有介绍

2.旋转动画是怎么实现的

3.小组件内部如何适配standBy

4.和灵动岛相关的功能怎么没有介绍

例如上述问题,这边都已经一一实现,介于作用域的原因,我相信高手都在民间.期待大家一起加入实现相关功能.感谢支持

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2023-12-13,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯音乐技术团队 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
    • 背景介绍
      • 小组件开发遇到的问题
        • 开发前的科普
        • 从遇到问题和解决问题开始
        • 4.如何让开发中的Intent不在指令app中显示
        • 5.widgetURL和Link跳转app问题
        • 6.如何实现歌词动画
        • 8.widgetBundle超出10个的数量限制问题
      • 总结
      相关产品与服务
      容器服务
      腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档