iOS14 Widget的使用

了解Widget
官方学习Widget Demo

Widgets显示相关的、可浏览的内容,允许用户快速访问你的App以获取更多详细信息。你的App可以提供多种小组件,让用户专注于对他们最重要的信息。

为我们的App添加新的Widget必须要明白其本身的限制:

  1. 弱交互。与之前仅可放置在负一屏的小部件相比,新版 Widget 只能借由点击固定的区域,打开主 app 内的特定页面或跳转链接。无法在不开启 app 的情况下,完成各种交互。
  2. 展示框架限定。新的 widget 仅允许使用苹果在去年(2019 年)才推出的 SwiftUI 进行布局,而不是开发者更熟悉的 UIView 框架。SwiftUI 作为一种申明式的页面布局方式,会默认的符合 苹果标准的留白和间隙,从某种角度而言自由度没有 UIView 那么高。好处是视觉统一性会更好,苹果味儿更足,坏处是会有些雷同。
  3. 刷新频率。为了省电,所有 widget 的刷新频率也是由系统统一调度,会根据用户的使用频率来调节。

根据这些我们可以知道,使用Widget,主要就是用来展示非常重要的信息,作为快捷入口等。尽量简单,不要将Widget搞成很复杂的东西。

下边主要贴代码进行一下说明,不过关于Widget的创建这里就省略了,毕竟网上多得是,例如:创建一个Widget Extension

一、布局

  1. 关于ZStack、HStack、VStack的使用。做Widget当然要先了解SwiftUI的使用,而在SwiftUI中这个三种Stack是必不可少的。可参考:您一直在等待的完整SwiftUI文档(需要科学上网),另外一个就是要学会Spacer()的使用,这个很重要。
  2. 封装。因为你的小组件可能有各种尺寸的,且可能需要多个小组件。因此尽可能的封装到每一个小的按钮,以便重复使用。
  3. 尺寸自适应。前边的限制中提到SwiftUI有自身的标准,因此可能与你的设计稿不相符,而获取到当前Widget的大小就很重要了(不仅仅只获取当前小组件的大小类型)。
  4. 黑暗模式。基本现在大家都需要适配黑暗模式吧?那么Widget也需要,而且因为SwiftUI的原因,我们可以非常简单的实现它。比如你在Widget的Assets中Add New Asset时选择Color set,就可以看到白色和黑暗模式两种颜色,按照你的需要设置色值之后,在代码中使用名字就可以了,系统会根据当前的模式自动选择颜色。而图片的使用则需要代码去判断了,以上的几条内容都可以在下边的示例代码中找到。
// 代码示例

struct OneBtnForFeaturesOneView: View {
    let btnTitle: String
    let btnUrl: String
    
    @Environment(\.colorScheme)  var colorScheme
    //view使用@ViewBuilder声明,因为它使用的view类型不同。
    @ViewBuilder
    var body: some View {
        Link(destination: URL(string: btnUrl)!){
            ZStack {
                Color("color_btn_bg")
                    .cornerRadius(16.0)
                VStack {
                    HStack(alignment: .top, content: {
                        Image(uiImage: UIImage(named: colorScheme == .dark ? "white_icon_search" : "black_icon_search")!)
                            .resizable()
                            .frame(width: 20, height: 20, alignment: .center)
                        Text(btnTitle)
                            .foregroundColor(Color("color_btn_title"))
                            .font(.system(size: 14, weight: .medium, design: .default))
                    })
                    .padding(.all, 16)
                    Spacer()
                    HStack (alignment: .bottom, content: {
                        Spacer()
                        Image(uiImage: UIImage(named: colorScheme == .dark ? "img_logo_white" : "img_logo")!)
                            .resizable()
                            .frame(width: 28, height: 28, alignment: .center)
                    })
                    .padding(.all, 16)
                }
            }
        }
    }
}


struct NewWidgetEntryView : View {
    var entry: Provider.Entry
    //针对不同尺寸的 Widget 设置不同的 View
    @Environment(\.widgetFamily) var family // 尺寸环境变量
    
    //view使用@ViewBuilder声明,因为它使用的view类型不同。
    @ViewBuilder
    var body: some View {
        // 使用 GeometryReader 获取小组件的大小
        GeometryReader{ geo in
            ZStack {
                Color("color_theme")
                    .frame(width: geo.size.width, height: geo.size.height, alignment: .center)
            }
        }
    }
}

在以上代码中,我们就可以看到与布局相关的内容

  • OneBtnForFeaturesOneView这个函数本身就是一个Button的封装,传入对应的Title和Url即可展示与跳转
  • 在这两个函数中我们可以看到ZStack、VStack的使用
  • colorScheme == .dark 即表示当前为黑暗模式,然后判断需要展示的图片
  • Color(“color_btn_title”) 即自动根据当前模式展示我们设置好的颜色
  • GeometryReader{ geo in }方法可以得到当前Widget的尺寸大小geo.size

二、数据请求

  1. 使用Swift的方式。
  2. 封装。
  3. 请求失败/默认数据的处理。在添加小组件的页面看到的样式就是加载的默认数据的样式,还有因为网络的问题请求失败的样式,将默认数据封装一下返回即可。
struct PosterData {
    static func getTodayPoster(completion: @escaping (Result<Poster, Error>) -> Void) {
        let url = URL(string: "https://nowapi.navoinfo.cn/get/now/today")!
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            guard error==nil else{
                completion(.failure(error!))
                return
            }
            let poster=posterFromJson(fromData: data!)
            completion(.success(poster))
        }
        task.resume()
    }
    
    static func posterFromJson(fromData data:Data) -> Poster {
        let json = try! JSONSerialization.jsonObject(with: data, options: []) as! [String: Any]
        guard let result = json["result"] as? [String: Any] else{
            return Poster(author: "Now", content: "加载失败")
        }
        
        let author = result["author"] as! String
        let content = result["celebrated"] as! String
        let posterImage = result["poster_image"] as! String
        
        //图片同步请求
        var image: UIImage? = nil
        if let imageData = try? Data(contentsOf: URL(string: posterImage)!) {
            image = UIImage(data: imageData)
        }
        
        return Poster(author: author, content: content, posterImage: image)
    }
}

以上为一个请求示例,接口可用,类似于带图片的每日名言。来自:iOS14 Widget小组件开发(Widget Extension)

三、多个小组件以及点击跳转事件

多小组件非常的简单,其最重要的代码就是如下:

@main
struct Widgets: WidgetBundle {
    @WidgetBundleBuilder
    var body: some Widget {
        NewWidget()
        FeaturesFourWidget()
        FeaturesTwoWidget()
    }
}

添加 WidgetBundle{},然后将@main给它,在内部实现各自的一套小组件就可以了,而每套小组件都是小中大三种样式。

参考:iOS 14 小组件(2):WidgetExtension 自定义样式与交互

不过其中有一段是不对的。
iOS14 的Widget小组件可以使用AppDelegate中的 openURL 来打开。
而关键就在于,对于最小格式的systemSmall必须使用widgetURL,但是对于中大型的小组件,你的点击内容就需要包裹在Link中,记得封装就行,这样就很简单了。

主要参考的文档有:
Build Your First Widget in iOS 14 With WidgetKit(科学上网)

对了,还有个踩坑的,有问题可以看下。
iOS小组件Widget踩坑

Android PDF 生成技术调研

公司业务需要安卓客户端动态生成 pdf 文档并且打印出来,鉴于安卓本身对 pdf 支持并不是太强大,对比 ios 那样直接定制,批量生成,Android 本身 pdf 功能只能塞入 bitmap 就比较鸡肋。于是多方调研找了总结了相关实现。

一、WebView 内容 PDF 输出

Android SDK 21 api 的 webview 中提供了 createPrintDocumentAdapter 方法,可以用这个方法对 webview 内容生成 pdf

Creates a PrintDocumentAdapter that provides the content of this WebView for printing. The adapter works by converting the WebView contents to a PDF stream. The WebView cannot be drawn during the conversion process – any such draws are undefined. It is recommended to use a dedicated off screen WebView for the printing. If necessary, an application may temporarily hide a visible WebView by using a custom PrintDocumentAdapter instance wrapped around the object returned and observing the onStart and onFinish methods. 

此方法需要网页加载完成才能生成 pdf,如果数据过多,页面比较复杂,比较有可能会 OOM,目前一页内容生成的 pdf 只有不到 3M pdf 生成,如果选择非彩色模式,尝试了一下生成 108页,也最多3M的内容,比起后面方式要好很多,不需要考虑图片拼接,也需要像 itext 考虑字体。缺点就是用户操作过程会欠缺,另外因为由于内容是网页内容,会出现拆分到两页的内容的情况。相当于是一种廉价且比较稳定虽有缺点的解决方案了。
代表库有:
https://github.com/tejpratap46/PDFCreatorAndroid

二、使用 Apache PDFBox 库实现 PDF 输出

PDFBox 核心思路是插入 PDF 内容然后指定排版,然后输出到 PDF 文件中

// Define a content stream for adding to the PDF
contentStream = new PDPageContentStream(document, page);

// Write Hello World in blue text
contentStream.beginText();
contentStream.setNonStrokingColor(15, 38, 192);
contentStream.setFont(font, 12);
contentStream.newLineAtOffset(100, 700);
contentStream.showText("Hello World");
contentStream.endText();

// Load in the images
InputStream in = assetManager.open("falcon.jpg");
InputStream alpha = assetManager.open("trans.png");

// Draw a green rectangle
contentStream.addRect(5, 500, 100, 100);
contentStream.setNonStrokingColor(0, 255, 125);
contentStream.fill();

// Draw the falcon base image
PDImageXObject ximage = JPEGFactory.createFromStream(document, in);
contentStream.drawImage(ximage, 20, 20);

// Draw the red overlay image
Bitmap alphaImage = BitmapFactory.decodeStream(alpha);
PDImageXObject alphaXimage = LosslessFactory.createFromImage(document, alphaImage);
contentStream.drawImage(alphaXimage, 20, 20 );

// Make sure that the content stream is closed:
contentStream.close();    

可以从这个代码看出,通过代码指定 pdf 文字内容宽高及位置,目前这个代码是 pdfbox-android-lite 专门适配安卓,但是这个库对于字库非常不友好,并且不能够获取到系统的合适对应的显示字库,尤其是中文相关的,直接显示中文会崩溃。另外假如去强行开启读取系统全部的字体库,会导致 app OOM 异常,读取字库直接失败,因此 PDFBox 系列的只能针对字体支持友好的语言。
代表库有:
https://github.com/tripplemac/pdfbox-android-lite
https://github.com/TomRoush/PdfBox-Android
https://github.com/danfickle/openhtmltopdf

三、安卓系统提供标准生成的 pdf

 // create a new document
 PdfDocument document = new PdfDocument();

 // create a page description
 PageInfo pageInfo = new PageInfo.Builder(100, 100, 1).create();

 // start a page
 Page page = document.startPage(pageInfo);

 // draw something on the page
 View content = getContentView();
 content.draw(page.getCanvas());

 // finish the page
 document.finishPage(page);
 . . .
 // add more pages
 . . .
 // write the document content
 document.writeTo(getOutputStream());

 // close the document
 document.close();

标准系统生成的方式,主要方式是操纵 view 的 canvas,这个不需要关注是否是塞入文本,也不需要考虑塞入的图片,全部由 view 来确定。

此方法需动态生成画布才能生成 pdf,如果数据过多,页面比较复杂,比较有可能会 OOM
直接使用 TextView 生成一个例子,生成出来内容很小的 pdf 文件就有 4M 大小,而使用 webview 打印选择生成 pdf 复杂内容不到 3M,因此如果生成很简单的 pdf 内容就可以使用这种方式。毕竟简洁快速且有系统保证。
代表库:
https://github.com/IanDarwin/Android-Cookbook-Examples/tree/master/PdfShare

四、itext PDF

itext 有两个版本,最新版本是其官方发布的,但是如果使用要么使用他们的商业库,要么遵循他们的 AGPL 协议,前者需要支付使用费,后者需要开源项目本身,并且有对应限制条件。这两个库对中文支持也没有那么友好,与PDFbox 类似,需要确定字体,如果没有对应文字的字体就会显示乱码,建议如果只是练手复杂项目,或者纯个人使用可以用这种方式,但还是有比较大缺陷。如果使用这种方式还不如服务器搭建 js 生成 pdf 服务,相关字体由服务器生成,这样不用担心包体过大,或者对于特殊文字不能马上支持的情况了
代表库:
https://itextpdf.com/
https://github.com/LibrePDF/OpenPDF/tree/master/openpdf-fonts-extra

五、搭建 JS 生成 pdf

依托于目前强大的浏览器,或者强大的开源库,既可以搭建纯客户端的 js 服务,也可以搭建服务器后台服务生成 pdf,所需要的字体资源还有其他资源都通过本地或者服务器来提供,相对而言灵活一点。内容全部放在客户端,会导致包体过大,放到服务器处理完以后,生成好 pdf 之后,客户端进行下载或者展示也是一种不错的选择方式,当然这种方式会吃掉服务器的一些资源。这些库 github 上面很多 JavaScript 相关的项目
代表库:
https://github.com/search?q=pdf

总结:相对于 ios 系统开发的封闭,但是提供了稳定的系统环境,不用担心字体,发音资源的问题,Android 本身国内国外厂商会各种定制,Google Android 本身提供了很多功能,但是因为各种原因,各种机器上面资源会根据地区还有厂商做一定的优化,导致某些必须的资源不存在,开发起功能束手束脚的。仁者见仁智者见智,选择适合自己方式实现功能才是目的。