注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

iOS 14开发-网络

iOS
基础知识App如何通过网络请求数据?App 通过一个 URL 向特定的主机发送一个网络请求加载需要的资源。URL 一般是使用 HTTP(HTTPS)协议,该协议会通过 IP(或域名)定位到资源所在的主机,然后等待主机处理和响应。主机通过本次网络请求指...
继续阅读 »

基础知识

App如何通过网络请求数据?

客户服务器模型

  1. App 通过一个 URL 向特定的主机发送一个网络请求加载需要的资源。URL 一般是使用 HTTP(HTTPS)协议,该协议会通过 IP(或域名)定位到资源所在的主机,然后等待主机处理和响应。
  2. 主机通过本次网络请求指定的端口号找到对应的处理软件,然后将网络请求转发给该软件进行处理(处理的软件会运行在特定的端口)。针对 HTTP(HTTPS)请求,处理的软件会随着开发语言的不同而不同,如 Java 的 Tomcat、PHP 的 Apache、.net 的 IIS、Node.js 的 JavaScript 运行时等)
  3. 处理软件针对本次请求进行分析,分析的内容包括请求的方法、路径以及携带的参数等。然后根据这些信息,进行相应的业务逻辑处理,最后通过主机将处理后的数据返回(返回的数据一般为 JSON 字符串)。
  4. App 接收到主机返回的数据,进行解析处理,最后展示到界面上。
  5. 发送请求获取资源的一方称为客户端。接收请求提供服务的一方称为服务端

基本概念

URL

  • Uniform Resource Locator(统一资源定位符),表示网络资源的地址或位置。
  • 互联网上的每个资源都有一个唯一的 URL,通过它能找到该资源。
  • URL 的基本格式协议://主机地址/路径

HTTP/HTTPS

  • HTTP—HyperTextTransferProtocol:超文本传输协议。
  • HTTPS—Hyper Text Transfer Protocol over Secure Socket Layer 或 Hypertext Transfer Protocol Secure:超文本传输安全协议。

请求方法

  • 在 HTTP/1.1 协议中,定义了 8 种发送 HTTP 请求的方法,分别是GET、POST、HEAD、PUT、DELETE、OPTIONS、TRACE、CONNECT
  • 最常用的是 GET 与 POST

响应状态码

状态码描述含义
200Ok请求成功
400Bad Request客户端请求的语法出现错误,服务端无法解析
404Not Found服务端无法根据客户端的请求找到对应的资源
500Internal Server Error服务端内部出现问题,无法完成响应

请求响应过程

请求响应过程

JSON

  • JavaScript Object Notation。
  • 一种轻量级的数据格式,一般用于数据交互。
  • 服务端返回给 App 客户端的数据,一般都是 JSON 格式。

语法

  • 数据以键值对key : value形式存在。
  • 多个数据由,分隔。
  • 花括号{}保存对象。
  • 方括号[]保存数组。

key与value

  • 标准 JSON 数据的 key 必须用双引号""
  • JSON 数据的 value 类型:
    • 数字(整数或浮点数)
    • 字符串("表示)
    • 布尔值(true 或 false)
    • 数组([]表示)
    • 对象({}表示)
    • null

解析

  • 厘清当前 JSON 数据的层级关系(借助于格式化工具)。
  • 明确每个 key 对应的 value 值的类型。
  • 解析技术
    • Codable 协议(推荐)。
    • JSONSerialization。
    • 第三方框架。

URLSession

使用步骤

  1. 创建请求资源的 URL。
  2. 创建 URLRequest,设置请求参数。
  3. 创建 URLSessionConfiguration 用于设置 URLSession 的工作模式和网络设置。
  4. 创建 URLSession。
  5. 通过 URLSession 构建 URLSessionTask,共有 3 种任务。 (1)URLSessionDataTask:请求数据的 Task。  (2)URLSessionUploadTask:上传数据的 Task。 (3)URLSessionDownloadTask:下载数据的 Task。 
  6. 启动任务。
  7. 处理服务端响应,有 2 种方式。 (1)通过 completionHandler(闭包)处理服务端响应。 (2)通过 URLSessionDataDelegate(代理)处理请求与响应过程的事件和接收服务端返回的数据。

基本使用

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// get()
// post()
}

func get() {
// 1. 确定URL
let url = URL(string: "http://v.juhe.cn/toutiao/index?type=top&key=申请的key")
// 2. 创建请求
let urlRequest = URLRequest(url: url!)
// cachePolicy: 缓存策略,App最常用的缓存策略是returnCacheDataElseLoad,表示先查看缓存数据,没有缓存再请求
// timeoutInterval:超时时间
// let urlRequest = URLRequest(url: url!, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 5)
let config = URLSessionConfiguration.default
// 3. 创建URLSession
let session = URLSession(configuration: config)
// 4. 创建任务
let task = session.dataTask(with: urlRequest) { data, _, error in
if error != nil {
print(error!)
} else {
if let data = data {
print(String(data: data, encoding: .utf8)!)
}
}
}
// 5. 启动任务
task.resume()
}

func post() {
let url = URL(string: "http://v.juhe.cn/toutiao/index")
var urlRequest = URLRequest(url: url!)
// 指明请求方法
urlRequest.httpMethod = "POST"
// 指明参数
let params = "type=top&key=申请的key"
// 设置请求体
urlRequest.httpBody = params.data(using: .utf8)
let config = URLSessionConfiguration.default
// delegateQueue决定了代理方法在哪个线程中执行
let session = URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue())
let task = session.dataTask(with: urlRequest)
task.resume()
}
}

// MARK:- URLSessionDataDelegate
extension ViewController: URLSessionDataDelegate {
// 开始接收数据
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
// 允许接收服务器的数据,默认情况下请求之后不接收服务器的数据即不会调用后面获取数据的代理方法
completionHandler(URLSession.ResponseDisposition.allow)
}

// 获取数据
// 根据请求的数据量该方法可能会调用多次,这样data返回的就是总数据的一段,此时需要用一个全局的Data进行追加存储
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
let result = String(data: data, encoding: .utf8)
if let result = result {
print(result)
}
}

// 获取结束
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
print(error)
} else {
print("=======成功=======")
}
}
}

注意:如果网络请求是 HTTP 而非 HTTPS,默认情况下,iOS 会阻断该请求,此时需要在 Info.plist 中进行如下配置。

<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>

URL转码与解码

  • 当请求参数带中文时,必须进行转码操作。
let url = "https://www.baidu.com?name=张三"
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
print(url) // URL中文转码
print(url.removingPercentEncoding!) // URL中文解码

  • 有时候只需要对URL中的中文处理,而不需要针对整个URL。
let str = "阿楚姑娘"
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
let url = URL(string: "https://music.163.com/#/search/m/?s=\(str)&type=1")

下载数据

class ViewController: UIViewController {
// 下载进度
@IBOutlet var downloadProgress: UIProgressView!
// 下载图片
@IBOutlet var downloadImageView: UIImageView!

override func viewDidLoad() {
super.viewDidLoad()

download()
}

func download() {
let url = URL(string: "http://172.20.53.240:8080/AppTestAPI/wall.png")!
let request = URLRequest(url: url)
let session = URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: OperationQueue())
let task = session.downloadTask(with: request)
task.resume()
}
}

extension ViewController: URLSessionDownloadDelegate {
// 下载完成
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
// 存入沙盒
let savePath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!
// 文件类型根据下载的内容决定
let fileName = "\(Int(Date().timeIntervalSince1970)).png"
let filePath = savePath + "/" + fileName
print(filePath)
do {
try FileManager.default.moveItem(at: location, to: URL(fileURLWithPath: filePath))
// 显示到界面
DispatchQueue.main.async {
self.downloadImageView.image = UIImage(contentsOfFile: filePath)
}
} catch {
print(error)
}
}

// 计算进度
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
DispatchQueue.main.async {
self.downloadProgress.setProgress(Float(totalBytesWritten) / Float(totalBytesExpectedToWrite), animated: true)
}
}
}

上传数据

上传数据需要服务端配合,不同的服务端代码可能会不一样,下面的上传代码适用于本人所写的服务端代码

  • 数据格式。

上传数据格式

  • 实现。
class ViewController: UIViewController {
let YFBoundary = "AnHuiWuHuYungFan"
@IBOutlet var uploadInfo: UILabel!
@IBOutlet var uploadProgress: UIProgressView!

override func viewDidLoad() {
super.viewDidLoad()

upload()
}

func upload() {
// 1. 确定URL
let url = URL(string: "http://172.20.53.240:8080/AppTestAPI/UploadServlet")!
// 2. 确定请求
var request = URLRequest(url: url)
// 3. 设置请求头
let head = "multipart/form-data;boundary=\(YFBoundary)"
request.setValue(head, forHTTPHeaderField: "Content-Type")
// 4. 设置请求方式
request.httpMethod = "POST"
// 5. 创建NSURLSession
let session = URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: OperationQueue())
// 6. 获取上传的数据(按照固定格式拼接)
var data = Data()
let header = headerString(mimeType: "image/png", uploadFile: "wall.png")
data.append(header.data(using: .utf8)!)
data.append(uploadData())
let tailer = tailerString()
data.append(tailer.data(using: .utf8)!)
// 7. 创建上传任务 上传的数据来自getData方法
let task = session.uploadTask(with: request, from: data) { _, _, error in
// 上传完毕后
if error != nil {
print(error!)
} else {
DispatchQueue.main.async {
self.uploadInfo.text = "上传成功"
}
}
}
// 8. 执行上传任务
task.resume()
}

// 开始标记
func headerString(mimeType: String, uploadFile: String) -> String {
var data = String()
// --Boundary\r\n
data.append("--" + YFBoundary + "\r\n")
// 文件参数名 Content-Disposition: form-data; name="myfile"; filename="wall.jpg"\r\n
data.append("Content-Disposition:form-data; name=\"myfile\";filename=\"\(uploadFile)\"\r\n")
// Content-Type 上传文件的类型 MIME\r\n\r\n
data.append("Content-Type:\(mimeType)\r\n\r\n")

return data
}

// 结束标记
func tailerString() -> String {
// \r\n--Boundary--\r\n
return "\r\n--" + YFBoundary + "--\r\n"
}

func uploadData() -> Data {
let image = UIImage(named: "wall.png")
let imageData = image!.pngData()
return imageData!
}
}

extension ViewController: URLSessionTaskDelegate {
// 上传进去
func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
DispatchQueue.main.async {
self.uploadProgress.setProgress(Float(totalBytesSent) / Float(totalBytesExpectedToSend), animated: true)
}
}

// 上传出错
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
print(error)
}
}
}

URLCache

  • 网络缓存有很多好处:节省流量、更快加载、断网可用。
  • 使用 URLCache 管理缓存区域的大小和数据。
  • 每一个 App 都默认创建了一个 URLCache 作为缓存管理者,可以通过URLCache.shared获取,也可以自定义。
// 创建URLCache
// memoryCapacity:内存缓存容量
// diskCapacity:硬盘缓存容量
// directory:硬盘缓存路径
let cache = URLCache(memoryCapacity: 10 * 1024 * 1024, diskCapacity: 100 * 1024 * 1024, directory: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first)
// 替换默认的缓存管理对象
URLCache.shared = cache

  • 常见属性与方法。
let url = URL(string: "http://v.juhe.cn/toutiao/index?type=top&key=申请的key")
let urlRequest = URLRequest(url: url!, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 5)
let cache = URLCache.shared

// 内存缓存大小
cache.memoryCapacity
// 硬盘缓存大小
cache.diskCapacity
// 已用内存缓存大小
cache.currentMemoryUsage
// 已用硬盘缓存大小
cache.currentDiskUsage
// 获取某个请求的缓存
let cacheResponse = cache.cachedResponse(for: urlRequest)
// 删除某个请求的缓存
cache.removeCachedResponse(for: urlRequest)
// 删除某个时间点开始的缓存
cache.removeCachedResponses(since: Date().addingTimeInterval(-60 * 60 * 48))
// 删除所有缓存
cache.removeAllCachedResponses()

WKWebView

  • 用于加载 Web 内容的控件。
  • 使用时必须导入WebKit模块。

基本使用

  • 加载网页。
// 创建URL
let url = URL(string: "https://www.abc.edu.cn")
// 创建URLRequest
let request = URLRequest(url: url!)
// 创建WKWebView
let webView = WKWebView(frame: UIScreen.main.bounds)
// 加载网页
webView.load(request)

  • 加载本地资源。
// 文件夹路径
let basePath = Bundle.main.path(forResource: "localWeb", ofType: nil)!
// 文件夹URL
let baseUrl = URL(fileURLWithPath: basePath, isDirectory: true)
// html路径
let filePath = basePath + "/index.html"
// 转成文件
let fileContent = try? NSString(contentsOfFile: filePath, encoding: String.Encoding.utf8.rawValue)
// 创建WKWebView
let webView = WKWebView(frame: UIScreen.main.bounds)
// 加载html
webView.loadHTMLString(fileContent! as String, baseURL: baseUrl)

注意:如果是本地资源是文件夹,拖进项目时,需要勾选Create folder references,然后用Bundle.main.path(forResource: "文件夹名", ofType: nil)获取资源路径。

与JavaScript交互

创建WKWebView

lazy var webView: WKWebView = {
// 创建WKPreferences
let preferences = WKPreferences()
// 开启JavaScript
preferences.javaScriptEnabled = true
// 创建WKWebViewConfiguration
let configuration = WKWebViewConfiguration()
// 设置WKWebViewConfiguration的WKPreferences
configuration.preferences = preferences
// 创建WKUserContentController
let userContentController = WKUserContentController()
// 配置WKWebViewConfiguration的WKUserContentController
configuration.userContentController = userContentController
// 给WKWebView与Swift交互起一个名字:callbackHandler,WKWebView给Swift发消息的时候会用到
// 此句要求实现WKScriptMessageHandler
configuration.userContentController.add(self, name: "callbackHandler")
// 创建WKWebView
var webView = WKWebView(frame: UIScreen.main.bounds, configuration: configuration)
// 让WKWebView翻动有回弹效果
webView.scrollView.bounces = true
// 只允许WKWebView上下滚动
webView.scrollView.alwaysBounceVertical = true
// 设置代理WKNavigationDelegate
webView.navigationDelegate = self
// 返回
return webView
}()

创建HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0,user-scalable=no"/>
</head>
<body>
iOS传过来的值:<span id="name"></span>
<button onclick="responseSwift()">响应iOS</button>
<script type="text/javascript">
// 给Swift调用
function sayHello(name) {
document.getElementById("name").innerHTML = name
return "Swift你也好!"
}
// 调用Swift方法
function responseSwift() {
// 这里的callbackHandler是创建WKWebViewConfiguration是定义的
window.webkit.messageHandlers.callbackHandler.postMessage("JavaScript发送消息给Swift")
}
</script>
</body>
</html>

两个协议

  • WKNavigationDelegate:判断页面加载完成,只有在页面加载完成后才能在实现 Swift 调用 JavaScript。WKWebView 调用 JavaScript:
// 加载完毕以后执行
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// 调用JavaScript方法
webView.evaluateJavaScript("sayHello('WebView你好!')") { (result, err) in
// result是JavaScript返回的值
print(result, err)
}
}

  • WKScriptMessageHandler:JavaScript 调用 Swift 时需要用到协议中的一个方法来。JavaScript 调用 WKWebView:
// Swift方法,可以在JavaScript中调用
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
print(message.body)
}

ViewController

class ViewController: UIViewController {
// 懒加载WKWebView
...

// 加载本地html
let html = try! String(contentsOfFile: Bundle.main.path(forResource: "index", ofType: "html")!, encoding: String.Encoding.utf8)

override func viewDidLoad() {
super.viewDidLoad()
// 标题
title = "WebView与JavaScript交互"
// 加载html
webView.loadHTMLString(html, baseURL: nil)
view.addSubview(webView)
}
}

// 遵守两个协议
extension ViewController: WKNavigationDelegate, WKScriptMessageHandler {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
...
}

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
...
}
}

SFSafariViewController

  • iOS 9 推出的一种 UIViewController,用于加载与显示 Web 内容,打开效果类似 Safari 浏览器的效果。
  • 使用时必须导入SafariServices模块。
import SafariServices

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
showSafariViewController()
}

func showSafariViewController() {
// URL
let url = URL(string: "https://www.baidu.com")
// 创建SFSafariViewController
let sf = SFSafariViewController(url: url!)
// 设置代理
sf.delegate = self
// 显示
present(sf, animated: true, completion: nil)
}
}

extension ViewController: SFSafariViewControllerDelegate {
// 点击左上角的完成(done)
func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
print(#function)
}

// 加载完成
func safariViewController(_ controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) {
print(#function)
}
}
收起阅读 »

iOS 14开发-定位与地图

iOS
定位CoreLocation 是 iOS 中用于设备定位的框架。通过这个框架可以实现定位进而获取位置信息如经度、纬度、海拔信息等。模块与常见类定位所包含的类都在CoreLocation模块中,使用时必须导入。CLLocationManager:定位管理器,可以...
继续阅读 »

定位

CoreLocation 是 iOS 中用于设备定位的框架。通过这个框架可以实现定位进而获取位置信息如经度、纬度、海拔信息等。

模块与常见类

  • 定位所包含的类都在CoreLocation模块中,使用时必须导入。
  • CLLocationManager:定位管理器,可以理解为定位不能自己工作,需要有个类对它进行全过程管理。
  • CLLocationManagerDelegate:定位管理代理,不管是定位成功与失败,都会有相应的代理方法进行回调。
  • CLLocation:表示某个位置的地理信息,包含经纬度、海拔等。
  • CLPlacemark:位置信息,包含的信息如国家、城市、街道等。
  • CLGeocoder:地理编码。

工作流程

  1. 创建CLLocationManager,设置代理并发起定位。
  2. 实现CLLocationManagerDelegate中定位成功和失败的代理方法。
  3. 在成功的代理方法中获取CLLocation对象并通过CLGeocoder进行反向地理编码获取对应的位置信息CLPlacemark
  4. 通过CLPlacemark获取具体的位置信息。

权限

授权对话框

  • 程序中调用requestWhenInUseAuthorization发起定位授权。
  • 程序中调用requestAlwaysAuthorization发起定位授权。

前台定位

  • 需要在 Info.plist 中配置Privacy - Location When In Use Usage Description
  • 程序中调用requestWhenInUseAuthorization发起定位授权。
  • 弹出的授权对话框新增了精确位置开关,同时新增了小地图展示当前位置。

后台定位

  • 需要勾选 Capabilities —> Background Modes —> Location updates
  • 程序中允许后台定位:locationManager.allowsBackgroundLocationUpdates = true
  • 此时授权分为 2 种情况: (1)Privacy - Location When In Use Usage Description + requestWhenInUseAuthorization:可以后台定位,但会在设备顶部出现蓝条(刘海屏设备会出现在左边刘海)。 (2)Privacy - Location When In Use Usage Description + Privacy - Location Always and When In Use Usage Description + requestAlwaysAuthorization:可以后台定位,不会出现蓝条。这种方式会出现 2 次授权对话框:第一次和前台定位一样,在同意使用While Using App模式后,继续使用定位才会弹出第二次,询问是否切换到Always模式。

精度控制

  • iOS 14 新增了一种定位精度控制,在定位授权对话框中有一个精度切换开关,可以切换精确和模糊定位(默认精确)。
  • 可以通过CLLocationManageraccuracyAuthorization属性获取当前的定位精度权限。
  • 当已经获得定位权限且当前用户选择的是模糊定位,则可以使用CLLocationManagerrequestTemporaryFullAccuracyAuthorization(withPurposeKey purposeKey: String, completion: ((Error?) -> Void)? = nil)方法申请一次临时精确定位权限,其中purposeKey为 Info.plist 中配置的Privacy - Location Temporary Usage Description Dictionary字段下某个具体原因的 key,可以设置多个 key 以应对不同的定位使用场景。
  • requestTemporaryFullAccuracyAuthorization方法并不能用于申请定位权限,只能用于从模糊定位升级为精确定位;如果没有获得定位权限,直接调用此 API 无效。
  • 如果不想使用精确定位,则可以在 Info.plist 中配置Privacy - Location Default Accuracy ReducedYES,此时申请定位权限的小地图中不再有精度切换开关。需要注意 2 点: (1)如果发现该字段不是 Bool 型,需要以源码形式打开 Info.plist,然后手动修改<key>NSLocationDefaultAccuracyReduced</key>为 Bool 型的值,否则无法生效。 (2)配置该字段后,如果 Info.plist 中还配置了Privacy - Location Temporary Usage Description Dictionary,则仍可以通过requestTemporaryFullAccuracyAuthorization申请临时的精确定位权限,会再次弹出授权对话框进行确认。

模拟器定位

由于定位需要 GPS,一般情况下需要真机进行测试。但对于模拟器,也可以进行虚拟定位,主要有 3 种方式。

  • 方式一
    (1)新建一个gpx文件,可以取名XXX.gpx,然后将自己的定位信息填写进 xml 对应的位置。 (2)gpx文件设置完成以后,首先需要运行一次 App,然后选择Edit Scheme,在Options中选择自己的gpx文件,这样模拟器运行的时候就会读取该文件的位置信息。然后可以选择Debug—>Simulate Location或底部调试栏上的定位按钮进行gpx文件或位置信息的切换。
<?xml version="1.0"?>
<gpx version="1.1" creator="Xcode">
<!--安徽商贸职业技术学院 谷歌地球:31.2906511800,118.3623587000-->
<wpt lat="31.2906511800" lon="118.3623587000">
<name>安徽商贸职业技术学院</name>
<cmt>中国安徽省芜湖市弋江区文昌西路24号 邮政编码: 241002</cmt>
<desc>中国安徽省芜湖市弋江区文昌西路24号 邮政编码: 241002</desc>
</wpt>
</gpx>

  • 方式二:运行程序开始定位 —> 模拟器菜单 —> Features —> Location —> Custom Location —> 输入经纬度。

实现步骤

  1. 导入CoreLocation模块。
  2. 创建CLLcationManager对象,设置参数和代理,配置 Info.plist 并请求定位授权。
  3. 调用CLLcationManager对象的startUpdatingLocation()requestLocation()方法进行定位。
  4. 实现代理方法,在定位成功的方法中进行位置信息的处理。
import CoreLocation
import UIKit

class ViewController: UIViewController {
// CLLocationManager
lazy var locationManager = CLLocationManager()
// CLGeocoder
lazy var gecoder = CLGeocoder()

override func viewDidLoad() {
super.viewDidLoad()

setupManager()
}

func setupManager() {
// 默认情况下每当位置改变时LocationManager就调用一次代理。通过设置distanceFilter可以实现当位置改变超出一定范围时LocationManager才调用相应的代理方法。这样可以达到省电的目的。
locationManager.distanceFilter = 300
// 精度 比如为10 就会尽量达到10米以内的精度
locationManager.desiredAccuracy = kCLLocationAccuracyBest
// 代理
locationManager.delegate = self
// 第一种:能后台定位但是会在顶部出现大蓝条(打开后台定位的开关)
// 允许后台定位
locationManager.allowsBackgroundLocationUpdates = true
locationManager.requestWhenInUseAuthorization()
// 第二种:能后台定位并且不会出现大蓝条
// locationManager.requestAlwaysAuthorization()
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// 以下2个方法都会调用代理方法
// 1. 发起位置更新(定位)会一直轮询,耗电
locationManager.startUpdatingLocation()
// 2. 只请求一次用户的位置,省电
// locationManager.requestLocation()
}
}

extension ViewController: CLLocationManagerDelegate {
// 定位成功
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if let location = locations.last {
// 反地理编码转换成具体的地址
gecoder.reverseGeocodeLocation(location) { placeMarks, _ in
// CLPlacemark -- 国家 城市 街道
if let placeMark = placeMarks?.first {
print(placeMark)
// print("\(placeMark.country!) -- \(placeMark.name!) -- \(placeMark.locality!)")
}
}
}
// 停止位置更新
locationManager.stopUpdatingLocation()
}

// 定位失败
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print(error.localizedDescription)
}
}

地图

  • 地图所包含的类都在MapKit模块中,使用时必须导入。
  • 除了可以显示地图,还支持在地图上进行标记处理。
  • 地图看似很复杂,其实它仅仅是一个控件 MKMapView,就和以前学习过的 UIButton、UITableView 等一样,可以在 storyboard 和代码中使用。
  • 地图上如果想要显示用户的位置,必须与定位配合,那么就需要创建定位管理器、设置权限等(参考定位知识),同时需要通过 storyboard 或者代码设置地图的相关属性。

准备工作

  1. 添加一个地图并设置相关属性。
  2. Info.plist 中配置定位权限。
  3. 创建 CLLocationManager 对象并请求定位权限。

基本使用

显示地图,同时显示用户所处的位置。点击用户的位置,显示一个气泡展示用户位置的具体信息。

import MapKit

class ViewController: UIViewController {
@IBOutlet var mapView: MKMapView!
lazy var locationManager: CLLocationManager = CLLocationManager()

override func viewDidLoad() {
super.viewDidLoad()

setupMapView()
}

func setupManager() {
locationManager.requestWhenInUseAuthorization()
// 不需要发起定位
}

func setupMapView() {
// 设置定位
setupManager()
// 地图类型
mapView.mapType = .hybridFlyover
// 显示兴趣点
mapView.showsPointsOfInterest = true
// 显示指南针
mapView.showsCompass = true
// 显示交通
mapView.showsTraffic = true
// 显示建筑
mapView.showsBuildings = true
// 显示级别
mapView.showsScale = true
// 用户跟踪模式
mapView.userTrackingMode = .followWithHeading
}
}

缩放级别

在之前功能的基础上实现地图的任意视角(“缩放级别”)。

// 设置“缩放级别”
func setRegion() {
if let location = location {
// 设置范围,显示地图的哪一部分以及显示的范围大小
let region = MKCoordinateRegion(center: mapView.userLocation.coordinate, latitudinalMeters: 500, longitudinalMeters: 500)
// 调整范围
let adjustedRegion = mapView.regionThatFits(region)
// 地图显示范围
mapView.setRegion(adjustedRegion, animated: true)
}
}

标注

在地图上可以添加标注来显示一个个关键的信息点,用于对用户的提示。

分类

  • MKPinAnnotationView:系统自带的标注,继承于 MKAnnotationView,形状跟棒棒糖类似,可以设置糖的颜色,和显示的时候是否有动画效果 (Swift 不推荐使用)。
  • MKMarkerAnnotationView:iOS 11 推出,建议使用。
  • MKAnnotationView:可以用指定的图片作为标注的样式,但显示的时候没有动画效果,如果没有指定图片会什么都不显示(自定义时使用)。

创建模型

class MapFlag: NSObject, MKAnnotation {
// 标题
let title: String?
// 副标题
let subtitle: String?
// 经纬度
let coordinate: CLLocationCoordinate2D
// 附加信息
let urlString: String

init(title: String?, subtitle: String?, coordinate: CLLocationCoordinate2D, urlString: String) {
self.title = title
self.subtitle = subtitle
self.coordinate = coordinate
self.urlString = urlString
}
}

添加标注

  • 添加系统标注,点击能够显示标题和副标题。
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let flag = MapFlag(title: "标题", subtitle: "副标题", coordinate: CLLocationCoordinate2D(latitude: 31.2906511800, longitude: 118.3623587000), urlString: "https://www.baidu.com")
mapView.addAnnotation(flag)
}

  • 添加系统标注,点击以气泡形式显示标题、副标题及自定义内容,此时需要重写地图的代理方法,返回标注的样式。
extension ViewController: MKMapViewDelegate {
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard let annotation = annotation as? MapFlag else {
return nil
}
// 如果是用户的位置,使用默认样式
if annotation == mapView.userLocation {
return nil
}
// 标注的标识符
let identifier = "marker"
// 获取AnnotationView
var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? MKMarkerAnnotationView
// 判空
if annotationView == nil {
annotationView = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier)
// 显示气泡
annotationView?.canShowCallout = true
// 左边显示的辅助视图
annotationView?.leftCalloutAccessoryView = UIImageView(image: UIImage(systemName: "heart"))
// 右边显示的辅助视图
let button = UIButton(type: .detailDisclosure, primaryAction: UIAction(handler: { _ in
print(annotation.urlString)
}))
annotationView?.rightCalloutAccessoryView = button
}

return annotationView
}
}

  • 如果希望标注的图标为自定义样式,只需要稍加更改代理方法并设置自己的标注图片即可。
extension ViewController: MKMapViewDelegate {
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard let annotation = annotation as? MapFlag else {
return nil
}
// 如果是用户的位置,使用默认样式
if annotation == mapView.userLocation {
return nil
}
// 标注的标识符
let identifier = "custom"
// 标注的自定义图片
let annotationImage = ["pin.circle.fill", "car.circle.fill", "airplane.circle.fill", "cross.circle.fill"]
// 获取AnnotationView
var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
// 判空
if annotationView == nil {
annotationView = MKAnnotationView(annotation: annotation, reuseIdentifier: identifier)
// 图标,每次随机取一个
annotationView?.image = UIImage(systemName: annotationImage.randomElement()!)
// 显示气泡
annotationView?.canShowCallout = true
// 左边显示的辅助视图
annotationView?.leftCalloutAccessoryView = UIImageView(image: UIImage(systemName: "heart"))
// 右边显示的辅助视图
let button = UIButton(type: .detailDisclosure, primaryAction: UIAction(handler: { _ in
print(annotation.urlString)
}))
annotationView?.rightCalloutAccessoryView = button
// 弹出的位置偏移
annotationView?.calloutOffset = CGPoint(x: -5.0, y: 5.0)
}

return annotationView
}
}

// 点击地图插入一个标注,标注的标题和副标题显示的是标注的具体位置
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let touchPoint = touches.first?.location(in: mapView)
// 将坐标转换成为经纬度,然后赋值给标注
let coordinate = mapView.convert(touchPoint!, toCoordinateFrom: mapView)
let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
let gecoder = CLGeocoder()
// 反地理编码转换成具体的地址
gecoder.reverseGeocodeLocation(location) { placeMarks, _ in
let placeMark = placeMarks?.first
if let placeMark = placeMark {
let flag = MapFlag(title: placeMark.locality, subtitle: placeMark.subLocality, coordinate: coordinate, urlString: "https://www.baidu.com")
self.mapView.addAnnotation(flag)
}
}
}
收起阅读 »

iOS 14开发- 通知

iOS
iOS 中的通知主要分为 2 种,本地通知和远程通知。本地通知使用步骤导入UserNotifications模块。申请权限。创建通知内容UNMutableNotificationContent,可以设置: (1)title:通知标题。 (2)subtitle:...
继续阅读 »

iOS 中的通知主要分为 2 种,本地通知和远程通知。

本地通知

使用步骤

  1. 导入UserNotifications模块。
  2. 申请权限。
  3. 创建通知内容UNMutableNotificationContent,可以设置: (1)title:通知标题。 (2)subtitle:通知副标题。 (3)body:通知体。 (4)sound:声音。 (5)badge:角标。 (6)userInfo:额外信息。 (7)categoryIdentifier:分类唯一标识符。 (8)attachments:附件,可以是图片、音频和视频,通过下拉通知显示。
  4. 指定本地通知触发条件,有 3 种触发方式: (1)UNTimeIntervalNotificationTrigger:一段时间后触发。 (2)UNCalendarNotificationTrigger:指定日期时间触发。 (3)UNLocationNotificationTrigger:根据位置触发。
  5. 根据通知内容和触发条件创建UNNotificationRequest
  6. UNNotificationRequest添加到UNUserNotificationCenter

案例

  • 申请授权(异步操作)。
import UserNotifications

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 请求通知权限
UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge]) { // 横幅,声音,标记
(accepted, error) in
if !accepted {
print("用户不允许通知")
}
}

return true
}

  • 发送通知。
import CoreLocation
import UIKit
import UserNotifications

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}

// 一段时间后触发
@IBAction func timeInterval(_ sender: Any) {
// 设置推送内容
let content = UNMutableNotificationContent()
content.title = "你好"
content.subtitle = "Hi"
content.body = "这是一条基于时间间隔的测试通知"
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "feiji.wav"))
content.badge = 1
content.userInfo = ["username": "YungFan", "career": "Teacher"]
content.categoryIdentifier = "testUserNotifications1"
setupAttachment(content: content)

// 设置通知触发器
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)

// 设置请求标识符
let requestIdentifier = "com.abc.testUserNotifications2"
// 设置一个通知请求
let request = UNNotificationRequest(identifier: requestIdentifier, content: content, trigger: trigger)
// 将通知请求添加到发送中心
UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
}

// 指定日期时间触发
@IBAction func dateInterval(_ sender: Any) {
// 设置推送内容
let content = UNMutableNotificationContent()
content.title = "你好"
content.body = "这是一条基于日期的测试通知"

// 时间
var components = DateComponents()
components.year = 2021
components.month = 5
components.day = 20
// 每周一上午8点
// var components = DateComponents()
// components.weekday = 2 // 周一
// components.hour = 8 // 上午8点
// components.minute = 30 // 30分
// 设置通知触发器
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)

// 设置请求标识符
let requestIdentifier = "com.abc.testUserNotifications3"
// 设置一个通知请求
let request = UNNotificationRequest(identifier: requestIdentifier, content: content, trigger: trigger)
// 将通知请求添加到发送中心
UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
}

// 根据位置触发
@IBAction func locationInterval(_ sender: Any) {
// 设置推送内容
let content = UNMutableNotificationContent()
content.title = "你好"
content.body = "这是一条基于位置的测试通知"

// 位置
let coordinate = CLLocationCoordinate2D(latitude: 31.29065118, longitude: 118.3623587)
let region = CLCircularRegion(center: coordinate, radius: 500, identifier: "center")
region.notifyOnEntry = true // 进入此范围触发
region.notifyOnExit = false // 离开此范围不触发
// 设置触发器
let trigger = UNLocationNotificationTrigger(region: region, repeats: true)
// 设置请求标识符
let requestIdentifier = "com.abc.testUserNotifications"

// 设置一个通知请求
let request = UNNotificationRequest(identifier: requestIdentifier, content: content, trigger: trigger)
// 将通知请求添加到发送中心
UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
}
}

extension ViewController {
func setupAttachment(content: UNMutableNotificationContent) {
let imageURL = Bundle.main.url(forResource: "img", withExtension: ".png")!
do {
let imageAttachment = try UNNotificationAttachment(identifier: "iamgeAttachment", url: imageURL, options: nil)
content.attachments = [imageAttachment]
} catch {
print(error.localizedDescription)
}
}
}

远程通知(消息推送)

远程通知是指在联网的情况下,由远程服务器推送给客户端的通知,又称 APNs(Apple Push Notification Services)。在联网状态下,所有设备都会与 Apple 服务器建立长连接,因此不管应用是打开还是关闭的情况,都能接收到服务器推送的远程通知。

远程通知流程.png

实现原理

  1. App 打开后首先发送 UDID 和 BundleID 给 APNs 注册,并返回 deviceToken(图中步骤 1,2,3)。
  2. App 获取 deviceToken 后,通过 API 将 App 的相关信息和 deviceToken 发送给应用服务器,服务器将其记录下来。(图中步骤 4)
  3. 当要推送通知时,应用服务器按照 App 的相关信息找到存储的 deviceToken,将通知和 deviceToken 发送给 APNs。(图中步骤 5)
  4. APNs 通过 deviceToken,找到指定设备的指定 App, 并将通知推送出去。(图中步骤 6)

实现步骤

证书方式

  1. 在开发者网站的 Identifiers 中添加 App IDs,并在 Capabilities 中开启 Push Notifications
  2. 在 Certificates 中创建一个 Apple Push Notification service SSL (Sandbox & Production) 的 APNs 证书并关联第一步中的 App IDs,然后将证书下载到本地安装(安装完可以导出 P12 证书)。
  3. 在项目中选择 Capability,接着开启 Push Notifications,然后在 Background Modes 中勾选 Remote notifications
  4. 申请权限。
  5. 通过UIApplication.shared.registerForRemoteNotifications()向 APNs 请求 deviceToken。
  6. 通过func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)获取 deviceToken。如果正常获取到 deviceToken,即表示注册成功,可以进行远程通知的推送,最后需要将其发送给应用服务器。注意:
    • App 重新启动后,deviceToken 不会变化。
    • App 卸载后重新安装,deviceToken 发生变化。
  7. 通知测试。

Token方式

  1. 在开发者网站的 Membership 中找到 Team ID 并记录。
  2. 在 Certificates, Identifiers & Profiles 的 Keys 中注册一个 Key 并勾选 Apple Push Notifications service (APNs) ,最后将生成的 Key ID 记录并将 P8 的 AuthKey 下载到本地(只能下载一次)。
  3. 在项目中选择 Capability,接着开启 Push Notifications,然后在 Background Modes 中勾选 Remote notifications
  4. 申请权限。
  5. 通过UIApplication.shared.registerForRemoteNotifications()向 APNs 请求 deviceToken。
  6. 通过func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)获取 deviceToken。如果正常获取到 deviceToken,即表示注册成功,可以进行远程通知的推送,最后需要将其发送给应用服务器。
  7. 通知测试。

Token Authentication 是 APNs 新推出的推送鉴权方式,它如下优势: (1)同一个开发者账号下的所有 App 无论是测试还是正式版都能使用同一个 Key 来发送而不需要为每个 App 生成证书。 (2)生成 Key 的过程相对简单,不需要繁琐的证书操作过程,并且它不再有过期时间,无需像证书那样需要定期重新生成。。

AppDelegate

import UserNotifications

class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 请求通知权限
UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge]) {
accepted, _ in
if !accepted {
print("用户不允许通知。")
}
}

// 向APNs请求deviceToken
UIApplication.shared.registerForRemoteNotifications()

return true
}

// deviceToken请求成功回调
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
var deviceTokenString = String()
let bytes = [UInt8](deviceToken)
for item in bytes {
deviceTokenString += String(format: "x", item & 0x000000FF)
}

// 打印获取到的token字符串
print(deviceTokenString)

// 通过网络将token发送给服务端
}

// deviceToken请求失败回调
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
print(error.localizedDescription)
}
}

注意:远程通知不支持模拟器(直接进入deviceToken请求失败回调),必须在真机测试。

测试

真机测试

  1. 将 App 安装到真机上。
  2. 通过软件(如 APNs)或者第三方进行测试,但都需要进行相关内容的设置。 (1)证书方式需要:P12 证书 + Bundle Identifier + deviceToken。 (2)Token 方式需要:P8 AuthKey + Team ID + Key ID + Bundle Identifier + deviceToken

模拟器测试—使用JSON文件

  • JSON文件。
{
"aps":{
"alert":{
"title":"测试",
"subtitle":"远程推送",
"body":"这是一条从远处而来的通知"
},
"sound":"default",
"badge":1
}
}

  • 命令。
xcrun simctl push booted developer.yf.TestUIKit /Users/yangfan/Desktop/playload.json

模拟器测试—使用APNS文件

另一种方法是将 APNs 文件直接拖到 iOS 模拟器中。准备一个后缀名为.apns的文件,其内容和上面的 JSON 文件差不多,但是添加了一个Simulator Target Bundle,用于描述 App 的Bundle Identifier

  • APNs文件。
{
"Simulator Target Bundle": "developer.yf.TestUIKit",
"aps":{
"alert":{
"title":"测试",
"subtitle":"远程推送",
"body":"这是一条从远处而来的通知"
},
"sound":"default",
"badge":1
}
}

前台处理

默认情况下,App 只有在后台才能收到通知提醒,在前台无法收到通知提醒,如果前台也需要提醒可以进行如下处理。

  • 创建 UNUserNotificationCenterDelegate。
class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
// 前台展示通知
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
// 前台通知一般不设置badge
completionHandler([.list, .banner, .sound])

// 如果不想显示某个通知,可以直接用 []
// completionHandler([])
}
}

  • 设置代理。
class AppDelegate: UIResponder, UIApplicationDelegate {
// 自定义通知回调类,实现通知代理
let notificationHandler = NotificationHandler()

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 设置代理
UNUserNotificationCenter.current().delegate = notificationHandler

return true
}
}

角标设置

  • 不论是本地还是远程通知,前台通知一般不会设置角标提醒,所以只需要针对后台通知处理角标即可。
  • 通知的角标不需要手动设置,会自动根据通知进行设置
// 手动添加角标
UIApplication.shared.applicationIconBadgeNumber = 10

// 清理角标
UIApplication.shared.applicationIconBadgeNumber = 0
收起阅读 »

【kotlin从摸索到探究】- delay函数实现原理

简介这片文章主要讲解kotlin中delay函数的实现原理,delay是一个挂起函数。kotlin携程使用过程中,经常使用到挂起函数,在我学习kotlin携程的时候,一些现象让我很是困惑,所以打算从源码角度来逐一分析。说明在分析delay源码实现过程中,由于对...
继续阅读 »

简介

这片文章主要讲解kotlindelay函数的实现原理,delay是一个挂起函数。kotlin携程使用过程中,经常使用到挂起函数,在我学习kotlin携程的时候,一些现象让我很是困惑,所以打算从源码角度来逐一分析

说明

在分析delay源码实现过程中,由于对kotlin有些语法还不是很熟悉,所以并不会把每一步将得很透彻,只会梳理一个大致的流程,如果讲解有误的地方,欢迎指出。

例子先行

fun main() = runBlocking {
println("${treadName()}======start")
launch {
println("${treadName()}======delay 1s start")
delay(1000)
println("${treadName()}======delay 1s end")
}

println("${treadName()}======delay 3s start")
delay(3000)
println("${treadName()}======delay 3s end")
// 延迟,保活进程
Thread.sleep(500000)
}

输出如下:

main======start
main======delay 3s start
main======delay 1s start
main======delay 1s end
main======delay 3s end

根据日志可以看出:

  1. 日志输出环境是在主线程。
  2. 执行3s延迟函数后,切换到了**launch**携程体执行。
  3. delay挂起函数恢复后执行各自的打印函数。

疑问:

如果真像打印日志输出一样,所以的操作都是在一个线程(主线程)完成,那么问题来了。**第一:按照Java线程知识,单线程执行是按照顺序的,是单条线的。那么不管delay里是何等骚操作,只要没有重新起线程,应该不能够像上面输入的那样吧,你说sleepwait,如果你这么想,那么你可以去补一补Java多线程基础知识了。猜想:**1. 难得真有什么我不知道的骚操作可以在一个线程里面同时执行delay和其它代码,真像很多人说的,携程性能很好,使用挂起函数可以不用启动新的线程,就可以异步执行,那真的就很不错。2. delay启动了新的线程,上面的现象只不过是进行了线程切换,那么如果多次调用 delay那么岂不是要创建很多线程,这性能问题和资源问题怎么解决。3. delay基于某种任务调度策略。

delay源码

public suspend inline fun <T> suspendCancellableCoroutine(
crossinline block: (CancellableContinuation<T>) -> Unit
): T =
suspendCoroutineUninterceptedOrReturn { uCont ->
val cancellable = CancellableContinuationImpl(uCont.intercepted(), resumeMode = MODE_CANCELLABLE)
cancellable.initCancellability()
block(cancellable)
cancellable.getResult()
}

cancellable是一个CancellableContinuationImpl对象,执行 block(cancellable),回到下面函数。

public suspend fun delay(timeMillis: Long) {
if (timeMillis <= 0) return // don't delay
return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
// if timeMillis == Long.MAX_VALUE then just wait forever like awaitCancellation, don't schedule.
if (timeMillis < Long.MAX_VALUE) {
cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
}
}
}

看一下cont.context.delayget方法

internal val CoroutineContext.delay: Delay get() = get(ContinuationInterceptor) as? Delay ?: DefaultDelay

如果get(ContinuationInterceptor)Delay类型对象,那么直接返回该对象,如果不是返回DefaultDelay变量,看一下DefaultDelay初始化可以知道,它是一个DefaultExecutor对象,继承了EventLoopImplBase类。

runBlocking执行过程中有这样一行代码createCoroutineUnintercepted(receiver, completion).intercepted()会被ContinuationInterceptor进行包装。所以上面cont.context.delay返回的就是被包装的携程体上下文。

查看scheduleResumeAfterDelay方法。

    public override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) {
val timeNanos = delayToNanos(timeMillis)
if (timeNanos < MAX_DELAY_NS) {
val now = nanoTime()
DelayedResumeTask(now + timeNanos, continuation).also { task ->
continuation.disposeOnCancellation(task)
schedule(now, task)
}
}
}

创建DelayedResumeTask对象,在also执行相关计划任务,看一下schedule方法。

    public fun schedule(now: Long, delayedTask: DelayedTask) {
when (scheduleImpl(now, delayedTask)) {
SCHEDULE_OK -> if (shouldUnpark(delayedTask)) unpark()
SCHEDULE_COMPLETED -> reschedule(now, delayedTask)
SCHEDULE_DISPOSED -> {} // do nothing -- task was already disposed
else -> error("unexpected result")
}
}

这里返回SCHEDULE_OK,执行unpark函数,这里用到了Java提供的LockSupport线程操作相关知识。

读取线程

  val thread = thread
  • 如果delay是当前携程的上下文 那么把延时任务加入到队列后,那么又是怎么达到线程延迟呢。回到runBlocking执行流程,会执行coroutine.joinBlocking()这样一行代码。

      fun joinBlocking(): T {
    registerTimeLoopThread()
    try {
    eventLoop?.incrementUseCount()
    try {
    while (true) {
    @Suppress("DEPRECATION")
    if (Thread.interrupted()) throw InterruptedException().also { cancelCoroutine(it) }
    val parkNanos = eventLoop?.processNextEvent() ?: Long.MAX_VALUE
    // note: process next even may loose unpark flag, so check if completed before parking
    if (isCompleted) break
    parkNanos(this, parkNanos)
    }
    } finally { // paranoia
    eventLoop?.decrementUseCount()
    }
    } finally { // paranoia
    unregisterTimeLoopThread()
    }
    // now return result
    val state = this.state.unboxState()
    (state as? CompletedExceptionally)?.let { throw it.cause }
    return state as T
    }

    执行:

     val parkNanos = eventLoop?.processNextEvent() ?: Long.MAX_VALUE

    看一下processNextEvent

      override fun processNextEvent(): Long {
    // unconfined events take priority
    if (processUnconfinedEvent()) return 0
    // queue all delayed tasks that are due to be executed
    val delayed = _delayed.value
    if (delayed != null && !delayed.isEmpty) {
    val now = nanoTime()
    while (true) {
    delayed.removeFirstIf {
    if (it.timeToExecute(now)) {
    enqueueImpl(it)
    } else
    false
    } ?: break // quit loop when nothing more to remove or enqueueImpl returns false on "isComplete"
    }
    }
    // then process one event from queue
    val task = dequeue()
    if (task != null) {
    task.run()
    return 0
    }
    return nextTime
    }

    从延迟队列取任务

    val delayed = _delayed.value

    挂起当前线程

    parkNanos(this, parkNanos)

    这里是一个while循环,当挂起时间到,线程唤醒,继续从任务队列中取任务执行。如果还是延迟任务,这根据当前时间点,计算线程需要挂起的时间,这也是为什么多个延迟任务好像是同时执行的。

  • 如果delay是DefaultExecutor 比如这个例子:携程上下文没有像CoroutineStart.DEFAULT那样进行包装。

    fun main() {
    GlobalScope.launch(start = CoroutineStart.UNDISPATCHED){
    println("${treadName()}======我开始执行了~")
    delay(1000)
    println("${treadName()}======全局携程~")
    }
    println("${treadName()}======我要睡觉~")
    Thread.sleep(3000)
    }

    然后调用DefaultExecutor类中thread的get方法:

      override val thread: Thread
    get() = _thread ?: createThreadSync()

    看一下createThreadSync函数

      private fun createThreadSync(): Thread {
    return _thread ?: Thread(this, THREAD_NAME).apply {
    _thread = this
    isDaemon = true
    start()
    }
    }

    创建一个叫"kotlinx.coroutines.DefaultExecutor的新线程,并且开始运行。这时候会执行DefaultExecutor中的run方法。在run方法中有这样一行代码:

    parkNanos(this, parkNanos)

    点进去看看:

    internal inline fun parkNanos(blocker: Any, nanos: Long) {
    timeSource?.parkNanos(blocker, nanos) ?: LockSupport.parkNanos(blocker, nanos)
    }

    调用Java提供的LockSupport.parkNanos(blocker, nanos)方法,阻塞当前线程,实现挂起,当达到阻塞的时间,恢复线程执行。

查看进行中线程情况方法

fun main() {
println("${treadName()}======doSuspendTwo")
Thread.sleep(500000)
}

运行main,通过命令jps找到对应Java进程(没有特别指定,进程名为文件名)号。

...
3406 KotlinCoreutinesSuspendKt
...

执行jstack 进程号查看进程对应的线程资源。


作者:Coolbreeze
链接:https://juejin.cn/post/7007769804505350152
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

【kotlin从摸索到探究】- 协程的执行流程

简介 这篇文章将从源码的角度,分析携程的执行流程,我们创建一个携程,系统是怎么进行调度的,什么时候执行的,是否需要创建新线程等等,带着这些疑问,一起往下看吧。 例子先行 fun main(): Unit = runBlocking { launch {...
继续阅读 »

简介


这篇文章将从源码的角度,分析携程的执行流程,我们创建一个携程,系统是怎么进行调度的,什么时候执行的,是否需要创建新线程等等,带着这些疑问,一起往下看吧。


例子先行


fun main(): Unit = runBlocking {
launch {
println("${treadName()}======1")
}
GlobalScope.launch {
println("${treadName()}======3")
}
launch {
println("${treadName()}======2")
}
println("${treadName()}======4")
Thread.sleep(2000)
}


输出如下:


DefaultDispatcher-worker-1======3
main======4
main======1
main======2

Process finished with exit code 0


根据打印,如果根据单线程执行流程来看,是不是感觉上面的日志打印顺序有点不好理解,下面我们就逐步来进行分解。




  • runBlocking携程体
    这里将其它代码省略到了,我这里都是按照一条简单的执行流程进行讲解。


    public fun <T> runBlocking(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T {

    val eventLoop: EventLoop?
    val newContext: CoroutineContext
    ...
    if (contextInterceptor == null) {
    eventLoop = ThreadLocalEventLoop.eventLoop
    newContext = GlobalScope.newCoroutineContext(context + eventLoop)
    }
    ...
    val coroutine = BlockingCoroutine<T>(newContext, currentThread, eventLoop)
    coroutine.start(CoroutineStart.DEFAULT, coroutine, block)
    return coroutine.joinBlocking()
    }


    看一下eventLoop的初始化,会 在当前线程(主线程)创建BlockingEventLoop对象。


    internal val eventLoop: EventLoop
    get() = ref.get() ?: createEventLoop().also { ref.set(it) }

    internal actual fun createEventLoop(): EventLoop = BlockingEventLoop(Thread.currentThread())


    看一下newContext初始化,这里会对携程上下文进行组合,返回新的上下文。最后返回的是一个BlockingEventLoop对象。


    public actual fun CoroutineScope.newCoroutineContext(context: CoroutineContext): CoroutineContext {
    val combined = coroutineContext + context
    val debug = if (DEBUG) combined + CoroutineId(COROUTINE_ID.incrementAndGet()) else combined
    return if (combined !== Dispatchers.Default && combined[ContinuationInterceptor] == null)
    debug + Dispatchers.Default else debug
    }


    开始对携程进行调度


     coroutine.start(CoroutineStart.DEFAULT, coroutine, block)


    看一下执行这句代码之前,各变量的值


    image


    而上面的代码最终调用的是CoroutineStart.DEFAULTinvoke方法。


      public operator fun <T> invoke(block: suspend () -> T, completion: Continuation<T>): Unit =
    when (this) {
    DEFAULT -> block.startCoroutineCancellable(completion)
    ATOMIC -> block.startCoroutine(completion)
    UNDISPATCHED -> block.startCoroutineUndispatched(completion)
    LAZY -> Unit // will start lazily
    }


    我们使用的是DEFAULT启动模式。然后会执行resumeCancellableWith方法。


      inline fun resumeCancellableWith(
    result: Result<T>,
    noinline onCancellation: ((cause: Throwable) -> Unit)?
    ) {
    val state = result.toState(onCancellation)
    if (dispatcher.isDispatchNeeded(context)) {
    _state = state
    resumeMode = MODE_CANCELLABLE
    dispatcher.dispatch(context, this)
    } else {
    executeUnconfined(state, MODE_CANCELLABLE) {
    if (!resumeCancelled(state)) {
    resumeUndispatchedWith(result)
    }
    }
    }
    }


    dispatcherBlockingEventLoop对象,没有重写isDispatchNeeded,默认返回true。然后调用dispatch继续进行分发。BlockingEventLoop继承了EventLoopImplBase并调用其dispatch方法。把任务加入到队列中。


    public final override fun dispatch(context: CoroutineContext, block: Runnable) = enqueue(block)


    回到最开始,在coroutine.start(CoroutineStart.DEFAULT, coroutine, block)执行完,还执行了coroutine.joinBlocking()看一下实现。


        fun joinBlocking(): T {
    registerTimeLoopThread()
    try {
    eventLoop?.incrementUseCount()
    try {
    while (true) {
    @Suppress("DEPRECATION")
    if (Thread.interrupted()) throw InterruptedException().also { cancelCoroutine(it) }
    val parkNanos = eventLoop?.processNextEvent() ?: Long.MAX_VALUE
    // note: process next even may loose unpark flag, so check if completed before parking
    if (isCompleted) break
    parkNanos(this, parkNanos)
    }
    } finally { // paranoia
    eventLoop?.decrementUseCount()
    }
    } finally { // paranoia
    unregisterTimeLoopThread()
    }
    // now return result
    val state = this.state.unboxState()
    (state as? CompletedExceptionally)?.let { throw it.cause }
    return state as T
    }


    执行val parkNanos = eventLoop?.processNextEvent() ?: Long.MAX_VALUE,取出任务进行执行,也就是runBlocking携程体。




  • launch {} 执行流程


    public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
    ): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
    LazyStandaloneCoroutine(newContext, block) else
    StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
    }

    因为launch是直接在runBlocking(父携程体)里新的创建的子携程体,所以执行流程上和之前将的差不多,只不过不会像runBlocking再去创建BlockingEventLoop对象,而是直接用runBlocking(父携程体)的,然后把任务加到里面,所以通过这种方式其实就是单线程对任务的调度而已。所以在runBlocking(父携程体)内通过launch启动再多的携程体,其实都是在同一线程,按照任务队列的顺序执行的。





根据上面日志输出,并没有先执行两个launch携程体,这是为什么呢,根据上面的讲解,应用知道,runBlocking(父携程体)是第一被添加的队列的任务,其次是launch,所以是这样的顺序。那可以让launch立即执行吗?答案是可以的,这就要说携程的启动模式了。





  • CoroutineStart 是协程的启动模式,存在以下4种模式:



    1. DEFAULT 立即调度,可以在执行前被取消

    2. LAZY 需要时才启动,需要start、join等函数触发才可进行调度

    3. ATOMIC 立即调度,协程肯定会执行,执行前不可以被取消

    4. UNDISPATCHED 立即在当前线程执行,直到遇到第一个挂起点(可能切线程)



    我们使用UNDISPATCHED就可以使携程体马上在当前线程执行。看一下是怎么实现的。看一下实现:





使用这种启动模式执行UNDISPATCHED -> block.startCoroutineUndispatched(completion)方法。


internal fun <T> (suspend () -> T).startCoroutineUndispatched(completion: Continuation<T>) {
startDirect(completion) { actualCompletion ->
withCoroutineContext(completion.context, null) {
startCoroutineUninterceptedOrReturn(actualCompletion)
}
}
}

大家可以自己点击去看一下,大概就是会立即执行携程体,而不是将任务放入队列。



但是GlobalScope.launch却不是按照这样的逻辑,这是因为GlobalScope.launch启动的全局携程,是一个独立的携程体了,并不是runBlocking(父携程体)子携程。看一下通过GlobalScope.launch有什么不同。





  • GlobalScope.launch执行流程



    1. 启动全局携程


    GlobalScope.launch

    newCoroutineContext(context)返回Dispatchers.Default对象。而DefaultScheduler继承了ExperimentalCoroutineDispatcher类。看一下ExperimentalCoroutineDispatcher中的dispatch代码:


     override fun dispatch(context: CoroutineContext, block: Runnable): Unit =
    ...
    coroutineScheduler.dispatch(block)
    ...


    看一下coroutineScheduler初始化


    private fun createScheduler() = CoroutineScheduler(corePoolSize, maxPoolSize, idleWorkerKeepAliveNs, schedulerName)

    CoroutineScheduler实现了Executor接口,里面还有两个全局队列和线程池相关的参数。


    @JvmField
    val globalCpuQueue = GlobalQueue()
    @JvmField
    val globalBlockingQueue = GlobalQueue()


    继续调用CoroutineScheduler中的dispatch方法


      fun dispatch(block: Runnable, taskContext: TaskContext = NonBlockingContext, tailDispatch: Boolean = false) {
    trackTask() // this is needed for virtual time support
    val task = createTask(block, taskContext)
    // try to submit the task to the local queue and act depending on the result
    val currentWorker = currentWorker()
    val notAdded = currentWorker.submitToLocalQueue(task, tailDispatch)
    if (notAdded != null) {
    if (!addToGlobalQueue(notAdded)) {
    // Global queue is closed in the last step of close/shutdown -- no more tasks should be accepted
    throw RejectedExecutionException("$schedulerName was terminated")
    }
    }
    val skipUnpark = tailDispatch && currentWorker != null
    // Checking 'task' instead of 'notAdded' is completely okay
    if (task.mode == TASK_NON_BLOCKING) {
    if (skipUnpark) return
    signalCpuWork()
    } else {
    // Increment blocking tasks anyway
    signalBlockingWork(skipUnpark = skipUnpark)
    }
    }




    1. val task = createTask(block, taskContext)包装成TaskImpl对象。




    2. val currentWorker = currentWorker()当前是主线程,运行程序时由进程创建,肯定不是Worker对象,Worker是一个继承了Thread的类 ,并且在初始化时都指定为守护线程


      Worker存在5种状态:
      CPU_ACQUIRED 获取到cpu权限
      BLOCKING 正在执行IO阻塞任务
      PARKING 已处理完所有任务,线程挂起
      DORMANT 初始态
      TERMINATED 终止态







  1. val notAdded = currentWorker.submitToLocalQueue(task, tailDispatch)由于currentWorker是null,直接返回task对象。

  2. addToGlobalQueue(notAdded)根据任务是否是阻塞任务,将task添加到全局任务队列中。这里被添加到globalCpuQueue中。

  3. 执行signalCpuWork()来唤醒一个线程或者启动一个新的线程。


    fun signalCpuWork() {
if (tryUnpark()) return
if (tryCreateWorker()) return
tryUnpark()
}


 private fun tryCreateWorker(state: Long = controlState.value): Boolean {  
val created = createdWorkers(state)// 创建的的线程总数
val blocking = blockingTasks(state)// 处理阻塞任务的线程数量
val cpuWorkers = (created - blocking).coerceAtLeast(0)//得到非阻塞任务的线程数量
if (cpuWorkers < corePoolSize) {// 小于核心线程数量,进行线程的创建
val newCpuWorkers = createNewWorker()
if (newCpuWorkers == 1 && corePoolSize > 1) createNewWorker()// 当前非阻塞型线程数量为1,同时核心线程数量大于1时,再进行一个线程的创建,
if (newCpuWorkers > 0) return true
}
return false
}

// 创建线程
private fun createNewWorker(): Int {
synchronized(workers) {
...
val created = createdWorkers(state)// 创建的的线程总数
val blocking = blockingTasks(state)// 阻塞的线程数量
val cpuWorkers = (created - blocking).coerceAtLeast(0) // 得到非阻塞线程数量
if (cpuWorkers >= corePoolSize) return 0//超过最大核心线程数,不能进行新线程创建
if (created >= maxPoolSize) return 0// 超过最大线程数限制,不能进行新线程创建
...
val worker = Worker(newIndex)
workers[newIndex] = worker
require(newIndex == incrementCreatedWorkers())
worker.start()// 线程启动
return cpuWorkers + 1
}
}


那么这里面的任务又是怎么调度的呢,当全局任务被执行的时候,看一下Worker中的run方法:


 override fun run() = runWorker()

执行runWorker方法,该方法会从队列中找到执行任务,然后开始执行。详细代码,可以自行翻阅。



所以GlobalScope.launch使用的就是线程池,没有所谓的性能好。




  • Dispatchers调度器
    Dispatchers是协程中提供的线程调度器,用来切换线程,指定协程所运行的线程。,上面用的是默认调度器Dispatchers.Default



Dispatchers中提供了4种类型调度器:
Default 默认调度器:适合CPU密集型任务调度器 比如逻辑计算;
Main UI调度器
Unconfined 无限制调度器:对协程执行的线程不做限制,协程恢复时可以在任意线程;
IO调度器:适合IO密集型任务调度器 比如读写文件,网络请求等。





作者:Coolbreeze
链接:https://juejin.cn/post/7008083001884016648
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

使用 Kotlin 重写 AOSP 日历应用

两年前,Android 开源项目 (AOSP) 应用 团队开始使用 Kotlin 替代 Java 重构 AOSP 应用。之所以重构主要有两个原因: 一是确保 AOSP 应用能够遵循 Android 最佳实践,另外则是提供优先使用 Kotlin 进行应用开发的良...
继续阅读 »

两年前,Android 开源项目 (AOSP) 应用 团队开始使用 Kotlin 替代 Java 重构 AOSP 应用。之所以重构主要有两个原因: 一是确保 AOSP 应用能够遵循 Android 最佳实践,另外则是提供优先使用 Kotlin 进行应用开发的良好范例。Kotlin 之所以具有强大的吸引力,原因之一是其简洁的语法,很多情况下用 Kotlin 编写的代码块的代码数量相比于功能相同的 Java 代码块要更少一些。此外,Kotlin 这种具有丰富表现力的编程语言还具有其他各种优点,例如:




  • 空安全: 这一概念可以说是根植于 Kotlin 之中,从而帮助避免破坏性的空指针异常;




  • 并发: 正如 Google I/O 2019 中关于 Android 的描述,结构化并发 (structured concurrency) 能够允许使用协程简化后台的任务管理;




  • 兼容 Java: 尤其是在这次的重构项目中,Kotlin 与 Java 语言的兼容性能够让我们一个文件一个文件地进行 Kotlin 转换。




AOSP 团队在去年夏天发表了一篇文章,详细介绍了 AOSP 桌面时钟应用的转换过程。而今年,我们将 AOSP 日历应用从 Java 转换成了 Kotlin。在这次转换之前,应用的代码行数超过 18,000 行,在转换后代码库减少了约 300 行。在这次的转换中,我们沿袭了同 AOSP 桌面时钟转换过程中类似的技术,充分利用了 Kotlin 与 Java 语言的互操作性,对代码文件一一进行了转换,并在过程中使用独立的构建目标将 Java 代码文件替换为对应的 Kotlin 代码文件。因为团队中有两个人在进行此项工作,所以我们在 Android.bp 文件中为每个人创建了一个 exclude_srcs 属性,这样两个人就可以在减少代码合并冲突的前提下,都能够同时进行重构并推送代码。此外,这样还能允许我们进行增量测试,快速定位错误出现在哪些文件。


在转换任意给定的文件时,我们一开始先使用 Android Studio Kotlin 插件中提供的 从 Java 到 Kotlin 的自动转换工具。虽然该插件成功帮助我们转换了大部份的代码,但是还是会遇到一些问题,需要开发者手动解决。需要手动更改的部分,我们将会在本文接下来的章节中列出。


在将每个文件转换为 Kotlin 之后,我们手动测试了日历应用的 UI 界面,运行了单元测试,并运行了 Compatibility Test Suite (CTS) 的子集来进行功能验证,以确保不需要再进行任何的回归测试。


自动转换之后的步骤


上面提到,在使用自动转换工具之后,有一些反复出现的问题需要手动定位解决。在 AOSP 桌面时钟文章中,详细介绍了其中遇到的一些问题以及解决方法。如下列出了一些在进行 AOSP 日历转换过程中遇到的问题。


用 open 关键词标记父类


我们遇到的问题之一是 Kotlin 父类和子类之间的相互调用。在 Kotlin 中,要将一个类标记为可继承,必须得在类的声明中添加 open 关键字,对于父类中被子类覆盖的方法也要这样做。但是在 Java 中的继承是不需要使用到 open 关键字的。由于 Kotlin 和 Java 能够相互调用,这个问题直到大部分代码文件转换到了 Kotlin 才出现。


例如,在下面的代码片段中,声明了一个继承于 SimpleWeeksAdapter 的类:


class MonthByWeekAdapter(context: Context?, params:
    HashMap<String?, Int?>) : SimpleWeeksAdapter(context as Context, params) {//方法体}

由于代码文件的转换过程是一次一个文件进行的,即使是完全将 SimpleWeeksAdapter.kt 文件转换成 Kotlin,也不会在其类的声明中出现 open 关键词,这样就会导致一个错误。所以之后需要手动进行 open 关键词的添加,以便让 SimpleWeeksAdapter 类可以被继承。这个特殊的类声明如下所示:


open class SimpleWeeksAdapter(context: Context, params: HashMap<String?, Int?>?) {//方法体}

override 修饰符


同样地,子类中覆盖父类的方法也必须使用 override 修饰符来进行标记。在 Java 中,这是通过 @Override 注解来实现的。然而,虽然在 Java 中有相应的注解实现版本,但是自动转换过程中并没有为 Kotlin 方法声明中添加 override 修饰符。解决的办法是在所有适当的地方手动添加 override 修饰符。


覆写父类中的属性


在重构过程中,我们还遇到了一个属性覆写的异常问题,当一个子类声明了一个变量,而在父类中存在一个非私有的同名变量时,我们需要添加一个 override 修饰符。然而,即使子类的变量同父类变量的类型不同,也仍然要添加 override 修饰符。在某些情况下,添加 override 仍不能解决问题,尤其是当子类的类型完全不同的时候。事实上,如果类型不匹配,在子类的变量前添加 override 修饰符,并在父类的变量前添加 open 关键字,会导致一个错误:


type of *property name* doesn’t match the type of the overridden var-property

这个报错很让人疑惑,因为在 Java 中,以下代码可以正常编译:


public class Parent {
int num = 0;
}

class Child extends Parent {
String num = "num";
}

而在 Kotlin 中相应的代码就会报上面提到的错误:


class Parent {
var num: Int = 0
}

class Child : Parent() {
var num: String = "num"
}


这个问题很有意思,目前我们通过在子类中对变量重命名来规避了这个冲突。上面的 Java 代码会被 Android Studio 目前提供的代码转换器转换为有问题的 Kotlin 代码,这甚至 被报告为是一个 bug 了。


import 语句


在我们转换的所有文件中,自动转换工具都倾向于将 Java 代码中的所有 import 语句截断为 Kotlin 文件中的第一行。最开始这导致了一些很让人抓狂的错误,编译器会在整个代码中报 "unknown references" 的错误。在意识到这个问题后,我们开始手动地将 Java 中的 import 语句粘贴到 Kotlin 代码文件中,并单独对其进行转换。


暴露成员变量


默认情况下,Kotlin 会自动地为类中的实例变量生成 getter 和 setter 方法。然而,有些时候我们希望一个变量仅仅只是一个简单的 Java 成员变量,这可以通过使用 @JvmField 注解来实现。


@JvmField 注解 的作用是 "指示 Kotlin 编译器不要为这个属性生成 getter 和 setter 方法,并将其作为一个成员变量允许其被公开访问"。这个注解在 CalendarData 类 中特别有用,它包含了两个 static final 变量。通过对使用 val 声明的只读变量使用 @JvmField 注解,我们确保了这些变量可以作为成员变量被其他类访问,从而实现了 Java 和 Kotlin 之间的兼容性。


对象中的静态方法


在 Kotlin 对象中定义的函数必须使用 @JvmStatic 进行标记,以允许在 Java 代码中通过方法名,而非实例化来对它们进行调用。也就是说,这个注解使其具有了类似 Java 的方法行为,即能够通过类名调用方法。根据 Kotlin 的文档,"编译器会为对象的外部类生成一个静态方法,而对于对象本身会生成一个实例方法。"我们在 Utils 文件 中遇到了这个问题,当完成转换后,Java 类就变成了 Kotlin 对象。随后,所有在对象中定义的方法都必须使用 @JvmStatic 标记,这样就允许在其他文件中使用 Utils.method() 这样的语法来进行调用。值得一提的是,在类名和方法名之间使用 .INSTANCE (即 Utils.INSTANCE.method()) 也是一种选择,但是这不太符合常见的 Java 语法,需要改变所有对 Java 静态方法的调用。


性能评估分析


所有的基准测试都是在一台 96 核、176 GiB 内存的机器上进行的。本项目中分析用到的主要指标有所减少的代码行数、目标 APK 的文件大小、构建时间和首屏从启动到显示的时间。在对上述每个因素进行分析的同时,我们还收集了每个参数的数据并以表格的方式进行了展示。


减少的代码行数



从 Java 完全转换到 Kotlin 后,代码行数从 18,004 减少到了 17,729。这比原来的 Java 代码量 减少了大约 1.5%。虽然减少的代码量并不可观,但对于一些大型应用来说,这种转换对于减少代码行数的效果可能更为显著,可参阅 AOSP 桌面时钟 文中所举的例子。


目标 APK 大小



使用 Kotlin 编写的应用 APK 大小是 2.7 MB,而使用 Java 编写的应用 APK 大小是 2.6 MB。可以说这个差异基本可以忽略不计了,由于包含了一些额外的 Kotlin 库,所以 APK 体积上的增加,实际上是可以预期的。这种大小的增加可以通过使用 ProguardR8 来进行优化。


编译时间



Kotlin 和 Java 应用的构建时间是通过取 10 次从零进行完整构建的时间的平均值来计算的 (不包含异常值),Kotlin 应用的平均构建时间为 13 分 27 秒,而 Java 应用的平均构建时间为 12 分 6 秒。据一些资料 (如 "Java 和 Kotlin 的区别" 以及 "Kotlin 和 Java 在编译时间上的对比") 显示,Kotlin 的编译时间事实上比 Java 要更耗时,特别是对于从零开始的构建。一些分析断言,Java 的编译速度会快 10-15%,又有一些分析称这一数据为 15-20%。拿我们的例子进行从零开始完整构建所花费的时间来说,Java 的编译速度比 Kotlin 快 11.2%,尽管这个微小的差异并不在上述范围内,但这有可能是因为 AOSP 日历是一个相对较小的应用,仅有 43 个类。尽管从零开始的完整构建比较慢,但是 Kotlin 仍然在其他方面占有优势,这些优势更应该被考虑到。例如,Kotlin 相对于 Java,更简洁的语法通常可以保证较少的代码量,这使得 Kotlin 代码库更易维护。此外,由于 Kotlin 是一种更为安全有效的编程语言,我们可以认为完整构建时间较慢的问题可以忽略不计。


首屏显示的时间



我们使用了这种 方法 来测试应用从启动到完全显示首屏所需要的时间,经过 10 次试验后我们发现,使用 Kotlin 应用的平均时间约为 197.7 毫秒,而 Java 的则为 194.9 毫秒。这些测试都是在 Pixel 3a XL 设备上进行的。从这个测试结果可以得出结论,与 Kotlin 应用相比,Java 应用可能具有微小的优势;然而,由于平均时间非常接近,这个差异几乎可以忽略不计。因此,可以说 AOSP 日历应用转换到 Kotlin,并没有对应用的初始启动时间产生负面影响。


结论


将 AOSP 日历应用转换为 Kotlin 大约花了 1.5 个月 (6 周) 的时间,由 2 名实习生负责该项目的实施。一旦我们对代码库更加熟悉并更加善于解决反复出现的编译时、运行时和语法问题时,效率肯定会变得更高。总的来说,这个特殊的项目成功地展示了 Kotlin 如何影响现有的 Android 应用,并在对 AOSP 应用进行转换的路途中迈出了坚实的一步。


欢迎您 点击这里 向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!


作者:Android_开发者
链接:https://juejin.cn/post/7008056083331678245
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

中秋快乐!来看看满眼都是中秋气息的app页面吧~

前言:看了很久,大家是真的🐂🍺,月球绕地球都整出来了,那我也来给大家整上花活~然后送上中秋祝福:月儿圆又亮,月饼圆又甜,家家团圆相聚,人人欢心甜蜜,祝你家圆人圆事事圆,中秋愉快! 不妨点个赞啦,看到这篇文章的帅哥~ app中秋的引导界面:(完整效果截图在最后哦...
继续阅读 »

前言:看了很久,大家是真的🐂🍺,月球绕地球都整出来了,那我也来给大家整上花活~然后送上中秋祝福:月儿圆又亮,月饼圆又甜,家家团圆相聚,人人欢心甜蜜,祝你家圆人圆事事圆,中秋愉快!


不妨点个赞啦,看到这篇文章的帅哥~


app中秋的引导界面:(完整效果截图在最后哦~)


效果图.gif


功能解析:


1.状态变化:背景和展示出来的诗篇与日期有关,日期不同,背景和诗篇不同


2.文字特效:中秋祝福的诗篇会一字一字慢慢浮现


3.倒计时处理:人性化,用户不想看直接跳过


1.状态变化:


我们定义一个变量date来控制状态,获取当前的日期来进行判断:


int _date = 1; //控制状态
DateTime _dateTime = DateTime.now(); //获取当前时间

然后在初始化时进行判断:


@override
 void initState() {
   super.initState();
   if (_dateTime.day <= 19) {
     ///19号之前,人们都在回家的路上
     _date = 1;
  } else if (_dateTime.day == 20) {
     ///20号,人们回到家中,吃上团圆饭
     _date = 2;
  } else if (_dateTime.day == 21) {
     ///21号,中秋快乐
     _date = 3;
  } else {
     ///中秋过后,亲人回到忙碌的生活,期盼着下一次团聚
     _date = 4;
  }
}

关于flutter如何获取时间,我给大家列出来了(送给新人,大神看了就图一乐~)


DateTime dateTime= DateTime.now();
dateTime.day 今天是几号,int类型
dateTime.month
dateTime.year
dateTime.hour
dateTime.minute
dateTime.second
dateTime.millisecond
dateTime.millisecondsSinceEpoch

2.文字特效


就像开始的gif图显示的一样,文字一个个浮现出来,其实这个很简单,我们可以自己diy,但是,广大热心程序猿给我们提供了插件:animated_text_kit


使用起来也很简单:


AnimatedTextKit(
 animatedTexts: [
   TyperAnimatedText(
     "Test文字",
     textStyle: TextStyle(fontSize: 22),
     speed: const Duration(milliseconds: 200),
  ),
],
 isRepeatingAnimation: false,//不循环播放
)

而且还有很多很多的效果,这里给大家列了出来,需要的可以查看文章最下方的项目源码


当然,在这里也是有难点的,因为flutter的文字无法竖排,网上有改源码的(我觉得复杂了)问了下朋友,说使用RotatedBox这个widget,但是我这看个der啊,你这竖的一个妙啊!


屏幕截图 2021-09-14 190155.jpg


所以最后我选择使用给每个文字后面加上/n 我直接手动换行,求求大神来告诉我解决方法(要不我自己写个插件哈哈)


3.倒计时处理


我们搞前端的必须要做一个人性化的东西给客户是不是


手动跳转加上:


int _countdown = 5;//五秒倒计时
Timer _countdownTimer;//控制倒计时

当然我们需要一个方法来控制倒计时,以及倒计时结束跳转:


void _startRecordTime() {
 _countdownTimer = Timer.periodic(Duration(seconds: 1), (timer) {
   setState(() {
     if (_countdown <= 1) {
       ///此处编写你需要跳转的界面
        _countdownTimer.cancel();
        _countdownTimer = null;
    } else {
       _countdown -= 1;
    }
  });
});
}

当然,在倒计时结束或者跳转时,记得把界面销毁~


@override
void dispose() {
 super.dispose();
 print('启动页面结束');
 if (_countdownTimer != null && _countdownTimer.isActive) {
   _countdownTimer.cancel();
   _countdownTimer = null;
}
}

onTap: () {
 ///点击跳过,在此处可以写跳转
 print("点击跳过,在此处可以写跳转代码,记得销毁界面哦");
},

完整效果:
屏幕截图 2021-09-14 195121.jpg


屏幕截图 2021-09-14 195203.jpg


屏幕截图 2021-09-14 195320.jpg


屏幕截图 2021-09-14 195345.jpg


源码地址:gitee.com/Xiao-Ti/aut…


作者:阿Tya
链接:https://juejin.cn/post/7007756651197366303
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

React下一代状态管理库——recoil

引言 对于react状态管理库,大家比较熟悉的可能是Redux,但是redux虽然设计得比较简洁,但是他却有一些问题,比如需要写大量的模板代码;需要约定新的状态对象是全新的,如果我们不用全新的对象,可能会导致不更新,这是常见的redux状态不更新问题,所以需要...
继续阅读 »

引言


对于react状态管理库,大家比较熟悉的可能是Redux,但是redux虽然设计得比较简洁,但是他却有一些问题,比如需要写大量的模板代码;需要约定新的状态对象是全新的,如果我们不用全新的对象,可能会导致不更新,这是常见的redux状态不更新问题,所以需要开发者自己去保证,所以不得不引入例如immer这类的库;另外,redux本身是框架无关的库,他需要和redux-react结合才能在react中使用。使用我们不得不借助redux toolkit或者rematch这种内置了很多最佳实践的库以及重新设计接口的库,但与此同时也增加了开发者的学习成本。
所以react的状态管理的轮子层出不穷,下面将会介绍面向未来设计的react状态管理库——recoil。


简介


recoil 的 slogan 十分简单:一个react状态管理库(A state management library for React)。它不是一个框架无关的状态库,它是专门为react而生的。


和react一样,recoil也是facebook的开源的库。官方宣称有三个主要的特性:




  1. Minimal andReactish:最小化和react风格的api。




  2. Data-Flow Graph:数据流图。支持派生数据和异步查询都是纯函数,内部都是高效的订阅。




  3. Cross-App Observation: 跨应用监听,能够实现整体状态监听。




基本设计思想


假如有这么一个场景,相应状态改变我们 仅仅需要 更新list中的第二个节点和canvas的第二个节点。

如果没有使用第三外部状态管理库,使用context API可能是这样的:



我们可能需要很多个单独的provider,对应仅仅需要更新的节点,这样实际上使用状态的子节点的和Provider实际上是 耦合 的,我们使用状态的时候需要关心是否有相应的provider。
又假如我们使用的redux,其实如果只是某一个状态更新,其实所有的订阅函数都会重新运行,即使我们最后通过selector浅对比两次状态一样的,阻止更新react树,但是一旦订阅的节点数量非常多,实际上是会有性能问题的。


recoil把状态分为了一个个原子,react组件树只会订阅他们需要的状态。在这个场景中,组件树左边和右边的item订阅了不同的原子,当原子改变,他们只会更新相应的订阅的节点。

同时recoil也支持“派生状态”,也就是说已有的原子组合成一个新的状态(selector),并且新的状态也可以成为其他状态的依赖。

不仅支持同步的selector,recoil也支持异步的selector,recoil对selector的唯一要求就是他们必须是一个纯函数。

Recoil的设计思想就是我们把状态拆分一个一个的原子atom,再由selector派生出更多状态,最后React的组件树订阅自己需要的状态,当有原子状态更新,只有改变的原子及其下游节点有订阅他们的组件才会更新。也就是说,recoil其实构建了一个 有向无环图 ,这个图和react组件树正交,他的状态和react组件树是完全 解耦 的。


简单用法


吹了这么多先来看看简单的用法吧。
区别于redux是与框架无关的状态管理库,既然Recoil是专门为React设计的状态管理库,那么他的API满满的“react风格”。 Recoil 只支持hooks API,在使用上来说可以说十分简洁了。
下面看看 Demo


import {
RecoilRoot,
atom,
selector,
useRecoilState,
useRecoilValue
} from "recoil";

export default function App() {
return (
<RecoilRoot>
<Demo />
</RecoilRoot>
);
}

const textState = atom({
key: "textState",
default: ""
});

const charCountState = selector({
key:'charCountState',
get: ({get}) => {
// 要求是纯函数
const text = get(textState)
return text.length
}
})

function Demo() {
const [text, setText] = useRecoilState(textState);
const count = useRecoilValue(charCountState)
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<br />
Echo: {text}
<br />
charCount: {count}
</>
);
}



  • 类似于React Redux,recoil也有一个Provider——RecoilRoot,用于全局共享一些方法和状态。




  • atom(原子)是recoil中最小的状态单元,atom表示一个值可以被读、写、订阅,它必须有一个区别于其他atom保持 唯一性和不变性 的key。通过atom可以定义一个数据。




  • Selector 有点像React-Redux中的selector,同样是用来“派生”状态的,不过和React-Redux中不同是:




    • React-redux的selector是一个纯函数,当全局唯一的状态改变,它总是会运行,从全局唯一的状态运算出新的状态。




    • 而在recoil中,selector的 options.的get也要求是一个纯函数,其中传入其中的get方法用来获取其他atom。 当且仅当依赖的 atom 发生改变且有组件订阅selector ,它其实才会重新运算,这意味着计算的值是会被缓存下来的,当依赖没有发生改变,其实直接会从缓存中读取并返回。而selector返回的也是一个atom,这意味着派生状态其实也是一个原子,其实也可以作为其他selector的依赖。






很明显,recoil是通过get函数中的get入参来收集依赖的,recoil支持动态收集依赖,也就是说get可以在条件中调用:


const toggleState = atom({key: 'Toggle', default: false});

const mySelector = selector({
key: 'MySelector',
get: ({get}) => {
const toggle = get(toggleState);
if (toggle) {
return get(selectorA);
} else {
return get(selectorB);
}
},
});

异步


recoil天然支持异步,用法也十分简单,也不需要配置什么异步插件,看看 Demo


const asyncDataState = selector({
key: "asyncData",
get: async ({get}) => {
// 要求是纯函数
return await getAsyncData();
}
});

function AsyncComp() {
const asyncData = useRecoilValue(asyncDataState);
return <>{asyncData}</>;
}
function Demo() {
return (
<React.Suspense fallback={<>loading...</>}>
<AsyncComp />
</React.Suspense>
);
}

由于recoil天然支持react suspense的特性,所以使用useRecoilValue获取数据的时候,如果异步状态pending,那么默认将会抛出该promise,使用时需要外层使用React.Suspense,那么react就会显示fallback里面的内容;如果报错,也会抛出里面的内容,被外层的ErrorBoundary捕获。
如果你不想使用该特性,可以使用useRecoilValueLoadable直接获取异步状态, demo


function AsyncComp() {
const asyncState = useRecoilValueLoadable(asyncDataState);
if (asyncState.state === "loading") {
return <>loading...</>;
}
if (asyncState.state === "hasError") {
return <>has error....</>;
}
if (asyncState.state === "hasValue") {
return <>{asyncState.contents}</>;
}
return null;
}

另外注意默认异步的结果是会被缓存下来,其实所有的selector上游没有改变的结果都会被缓存下来。也就是说如果异步的依赖没有发生改变,那么不会重新执行异步函数,直接返回缓存的值。这也是为什么一直强调selector配置项get是纯函数的原因。


依赖外部变量


我们常常会遇到状态不纯粹的问题,如果状态其实是依赖外部的变量,recoil有selectorFamily支持:


const getUserInfoState = selectorFamily({
key: "userInfo",
get: (userId) => ({ get }) => {
return queryUserState({userId: id, xxx: get(xxx) });
},
});

function MyComponent({ userID }) {

const number = useRecoilValue(getUserInfoState(userID));
//...
}

这里外部的参数和key,会同时生成一个全局唯一的key,用于标识状态,也就是说如果外部变量没有变化或者依赖没有发生变化,不会重新计算状态,而是直接返回缓存值。


源码解析


如果说看到这里,仅仅实现上面那些简单例子的话,大家可能会说“就这”?实现起来应该不太难,这里有一个简单的 实现的版本 ,虽然功能差不多,但是架构完全不一样,recoil的源码继承了react源码的优良传统,就是十分难读。。。


其源码核心功能分为几个部分:




  • Graph 图相关的逻辑




  • Nodeatom和selector在内部统一抽象为node




  • RecoilRoot 主要是就是外部用的一些recoilRoot,




  • RecoilValue 对外部暴露的类型。也就说atom、selector的返回值。




  • hooks 使用的hooks相关的。




  • Snapshot 状态快照,提供状态记录和回滚。




  • 一些其他读不懂的代码。。。




下面就谈谈自己这几天看源码粗浅的认识,欢迎大佬们指正。


Concurrent mode 支持


为了防止把大家绕晕,先讲讲我最关心的问题,recoil是如何支持conccurent的思路,可能不太正确(网上没有资料参考,欢迎讨论)。


Cocurrent mode


先讲一讲什么是react的Cocurrent mode,官网的介绍是,一系列新的特性帮助ract应用保持响应式和并优雅的使用用户设备能力和网络速度。
react迁移到fiber架构就是为了concurrent mode的实现,React 在新的架构下实际上有两个阶段:




  • 渲染(rendering)阶段




  • 提交(commit)阶段




在渲染阶段,react 可以根据任务优先级对组件树进行渲染,所以当前渲染任务可能会因为优先级不够或者当前帧没有剩余时间而被中断。后续调度会重新执行当前任务渲染。


ui和state不一致的问题


因为react现在会放弃控制流,在渲染开始到渲染结束,任何事情都可能发生,一些的钩子被取消就是因为这个原因。而对于第三方状态库来说,比如说有一个异步请求在这段时间把外部的状态改变了,react会继续上一次打断的地方重新渲染,就会读到新的状态值。 就会发生 状态和 UI 不一致 的情况。


recoil的解决办法


整体数据结构



atom


atom实际上是调用baseAtom,baseAtom内部有闭包变量defaultLoadable一个用于记录当前的默认值。声明了getAtom函数和setAtom函数等,最后传给registerNode,完成注册。


function baseAtom(options){
// 默认值
let defaultLoadable = isPromise(options.default) ? xxxx : options.default

function getAtom(store,state){
if(state.atomValues.has(key)){
// 如果当前state里有这个key的值,直接返回。
return state.atomValues.get(key)
}else if(state.novalidtedAtoms.has(key)){
//.. 一些逻辑
}else{
return defaultLoadable;
}
}

function setAtom(store, state, newValue){
if (state.atomValues.has(key)) {
const existing = nullthrows(state.atomValues.get(key));
if (existing.state === 'hasValue' && newValue === existing.contents) {
// 如果相等就返回空map
return new Map();
}
}
//...
// 返回的的是key --> 新的loadableValue的Map
return new Map().set(key, loadableWithValue(newValue));
}

function invalidateAtom(){
//...
}



const node = registerNode(
({
key,
nodeType: 'atom',
get: getAtom,
set: setAtom,
init: initAtom,
invalidate: invalidateAtom,
// 忽略其他配置。。。
}),
);
return node;
}

function registerNode(){
if (nodes.has(node.key)) {
//...
}
nodes.set(node.key, node);

const recoilValue =
node.set == null
? new RecoilValueClasses.RecoilValueReadOnly(node.key)
: new RecoilValueClasses.RecoilState(node.key);

recoilValues.set(node.key, recoilValue);
return recoilValue;
}

selector


由于selector也可以传入set配置项,这里就不分析了。


function selector(options){
const {key, get} = options
const deps = new Set();
function selectorGet(){
// 检测是否有循环依赖
return detectCircularDependencies(() =>
getSelectorValAndUpdatedDeps(store, state),
);
}

function getSelectorValAndUpdatedDeps(){
const cachedVal = getValFromCacheAndUpdatedDownstreamDeps(store, state);
if (cachedVal != null) {
setExecutionInfo(cachedVal, store);
// 如果有缓存值直接返回
return cachedVal;
}
// 解析getter
const [loadable, newDepValues] = evaluateSelectorGetter(
store,
state,
newExecutionId,
);
// 缓存结果
maybeSetCacheWithLoadable(
state,
depValuesToDepRoute(newDepValues),
loadable,
);
//...
return lodable
}

function evaluateSelectorGetter(){
function getRecoilValue(recoilValue){
const { key: depKey } = recoilValue
dpes.add(key);
// 存入graph
setDepsInStore(store, state, deps, executionId);
const depLoadable = getCachedNodeLoadable(store, state, depKey);
if (depLoadable.state === 'hasValue') {
return depLoadable.contents;
}
throw depLoadable.contents;
}
const result = get({get: getRecoilValue});
const lodable = getLodable(result);
//...

return [loadable, depValues];
}

return registerNode<T>({
key,
nodeType: 'selector',
peek: selectorPeek,
get: selectorGet,
init: selectorInit,
invalidate: invalidateSelector,
//...
});
}
}

hooks


useRecoilValue && useRecoilValueLoadable




  • useRecoilValue底层实际上就是依赖useRecoilValueLoadable,如果useRecoilValueLoadable的返回值是promise,那么就把他抛出来。




  • useRecoilValueLoadable 首先是在useEffect里订阅RecoilValue的变化,如果发现变化不太一样,调用forceupdate重新渲染。返回值则是通过调用node的get方法拿到值为lodable类型的,返回出来。




function useRecoilValue<T>(recoilValue: RecoilValue<T>): T {
const storeRef = useStoreRef();
const loadable = useRecoilValueLoadable(recoilValue);
// 如果是promise就是throw出去。
return handleLoadable(loadable, recoilValue, storeRef);
}

function useRecoilValueLoadable_LEGACY(recoilValue){
const storeRef = useStoreRef();
const [_, forceUpdate] = useState([]);

const componentName = useComponentName();

useEffect(() => {
const store = storeRef.current;
const storeState = store.getState();
// 实际上就是在storeState.nodeToComponentSubscriptions里面建立 node --> 订阅函数的映射
const subscription = subscribeToRecoilValue(
store,
recoilValue,
_state => {
// 在代码里通过gkx开启一些特性,方便单元测试和代码迭代。
if (!gkx('recoil_suppress_rerender_in_callback')) {
return forceUpdate([]);
}
const newLoadable = getRecoilValueAsLoadable(
store,
recoilValue,
store.getState().currentTree,
);
// 小小的优化
if (!prevLoadableRef.current?.is(newLoadable)) {
forceUpdate(newLoadable);
}
prevLoadableRef.current = newLoadable;
},
componentName,
);
//...
// release
return subscription.release;
})

// 实际上就是调用node.get方法。然后做一些其他处理
const loadable = getRecoilValueAsLoadable(storeRef.current, recoilValue);

const prevLoadableRef = useRef(loadable);
useEffect(() => {
prevLoadableRef.current = loadable;
});
return loadable;
}

这里一个有意思的点是useComponentName的实现有一点点hack:由于我们通常会约定hooks的命名是use开头,所以可以通过调用栈去找第一个调用函数不是use开头的函数名,就是组件的名称。当然生产环境,由于代码混淆是不可用的。


function useComponentName(): string {
const nameRef = useRef();
if (__DEV__) {
if (nameRef.current === undefined) {
const frames = stackTraceParser(new Error().stack);
for (const {methodName} of frames) {
if (!methodName.match(/\buse[^\b]+$/)) {
return (nameRef.current = methodName);
}
}
nameRef.current = null;
}
return nameRef.current ?? '<unable to determine component name>';
}
return '<component name not available>';
}

useRecoilValueLoadable_MUTABLESOURCE基本上是一样的,除了订阅函数里我们从手动调用foceupdate变成了调用参数callback。


function useRecoilValueLoadable_MUTABLESOURCE(){
//...

const getLoadable = useCallback(() => {
const store = storeRef.current;
const storeState = store.getState();
//...
const treeState = storeState.currentTree;
return getRecoilValueAsLoadable(store, recoilValue, treeState);
}, [storeRef, recoilValue]);

const subscribe = useCallback(
(_storeState, callback) => {
const store = storeRef.current;
const subscription = subscribeToRecoilValue(
store,
recoilValue,
() => {
if (!gkx('recoil_suppress_rerender_in_callback')) {
return callback();
}
const newLoadable = getLoadable();
if (!prevLoadableRef.current.is(newLoadable)) {
callback();
}
prevLoadableRef.current = newLoadable;
},
componentName,
);
return subscription.release;
},
[storeRef, recoilValue, componentName, getLoadable],
);
const source = useRecoilMutableSource();
const loadable = useMutableSource(source, getLoadableWithTesting, subscribe);
const prevLoadableRef = useRef(loadable);
useEffect(() => {
prevLoadableRef.current = loadable;
});
return loadable;
}

useSetRecoilState & setRecoilValue


useSetRecoilState最终其实就是调用queueOrPerformStateUpdate,把更新放入更新队列里面等待时机调用


function useSetRecoilState(recoilState){
const storeRef = useStoreRef();
return useCallback(
(newValueOrUpdater) => {
setRecoilValue(storeRef.current, recoilState, newValueOrUpdater);
},
[storeRef, recoilState],
);
}

function setRecoilValue<T>(
store,
recoilValue,
valueOrUpdater,
) {
queueOrPerformStateUpdate(store, {
type: 'set',
recoilValue,
valueOrUpdater,
});
}

queueOrPerformStateUpdate,之后的操作比较复杂这里做简化为三步,如下;


function queueOrPerformStateUpdate(){
//...
//atomValues中设置值
state.atomValues.set(key, loadable);
// dirtyAtoms 中添加key。
state.dirtyAtoms.add(key);
//通过storeRef拿到。
notifyBatcherOfChange.current()
}

Batcher


recoil内部自己实现了一个批量更新的机制。


function Batcher({
setNotifyBatcherOfChange,
}: {
setNotifyBatcherOfChange: (() => void) => void,
}) {
const storeRef = useStoreRef();

const [_, setState] = useState([]);
setNotifyBatcherOfChange(() => setState({}));

useEffect(() => {
endBatch(storeRef);
});

return null;
}


function endBatch(storeRef) {
const storeState = storeRef.current.getState();
const {nextTree} = storeState;
if (nextTree === null) {
return;
}
// 树交换
storeState.previousTree = storeState.currentTree;
storeState.currentTree = nextTree;
storeState.nextTree = null;

sendEndOfBatchNotifications(storeRef.current);
}

function sendEndOfBatchNotifications(store: Store) {
const storeState = store.getState();
const treeState = storeState.currentTree;
const dirtyAtoms = treeState.dirtyAtoms;
// 拿到所有下游的节点。
const dependentNodes = getDownstreamNodes(
store,
treeState,
treeState.dirtyAtoms,
);
for (const key of dependentNodes) {
const comps = storeState.nodeToComponentSubscriptions.get(key);

if (comps) {
for (const [_subID, [_debugName, callback]] of comps) {
callback(treeState);
}
}
}
}
//...
}

总结


虽然关于react的状态管理库很多,但是recoil的一些思想还是很先进,社区里面对这个新轮子也很多挂关注,目前githubstar14k。因为recoil目前还不是稳定版本,所以npm下载量并不高,也不建议大家在生产环境中使用。不过相信随着react18的发布,recoil也会更新为稳定版本,它的使用将会越来越多,到时候大家可以尝试一下。


链接:https://juejin.cn/post/7006253866610229256

收起阅读 »

css做‘展开收起’功能,借鉴大佬思路

开局一张图 上图所示,多行文本的展开收起是一个很常见的交互效果。 实现这一类布局和交互难点主要一下几点: 位于多行文本右下角的“展开收起”按钮 “展开”和“收起”两种状态的切换 当文本不超过指定行数时,不显示“展开收起”按钮 在此之前,单独看这个布局,即...
继续阅读 »

开局一张图


more.gif


上图所示,多行文本的展开收起是一个很常见的交互效果。


实现这一类布局和交互难点主要一下几点:



  • 位于多行文本右下角的“展开收起”按钮

  • “展开”和“收起”两种状态的切换

  • 当文本不超过指定行数时,不显示“展开收起”按钮


在此之前,单独看这个布局,即便是配合JavaScript也不那么容易做出好看的交互效果。经过各方学习,发现纯CSS也能完美实现。


第一步,"展开收起"按钮


多行文本截断

假设有如下的一段html结构


<div class='more-text'>
如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。
</div>

多行文本超出展示省略号的方式,大家平常也用得蛮多吧,关键代码如下


.more-text {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}

image.png


按钮右下角环绕效果

<div class='more-text'>
<div class='more-btn'>展开</div>
如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。
</div>

.more-btn{
float: left;
/*其他装饰样式*/
}

image.png


换为右浮动


.more-btn{
float: right;
/*其他装饰样式*/
}


image.png


再移到右下角


.more-btn{
float: right;
margin-top: 50px;
/*其他装饰样式*/
}

image.png


不难看出,按钮确实到了右下角,但按钮上方空白空间太大了。并不是我们希望的效果。


此时,借鉴伪元素配合多个浮动元素来完成。


.more-text::before {
content: '';
float: right;
width: 10px;
height: 50px;
background: red;
}
.more-btn{
float: right;
clear: both;
/*其他装饰样式*/
}


image.png


如上图,当按钮和伪元素before都浮动,并且按钮clear: both,此时,伪元素before成功将按钮顶到了右下角。让伪元素before的宽度去掉便出现如下效果。


.more-text::before {
content: '';
float: right;
width: 0;
height: 50px;
background: red;
}

image.png


如你所见,按钮环绕效果非~常完美符合预期。


但是before高度是固定的50px,不一定会满足场景所需。还需修改为calc动态计算。


.more-text::before {
content: '';
float: right;
width: 0;
height: calc(100% - 20px);
/*100%减去一个按钮的高度即可*/
background: red;
}

image.png


很可惜,calc并没有达到理想的效果。


为什么呢?打开控制台可以发现,calc计算所得高度为0。怎么会这样呢?原因其实是因为父级元素没有设置高度,calc里面的 100% 便失效了。但问题在于,这里所需要的高度是动态变化的,不可能给父级定下一个固定高度。


至此,我们需要对布局进行修改。利用flex布局。大概的方法就是在 flex 布局 的子项中,可以通过百分比来计算变化高度。


修改如下,给.more-text再包裹一层,再设置 display: flex


<div class='more-wrapper'>
<div class='more-text'>
<div class='more-btn'>展开</div>
如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。
</div>
</div>

.more-wrapper{
display: flex;
}

这样修改之后,calc的计算高度便能够生效。如下图所示。


image.png


至此,按钮右下角环绕效果就基本完成了。配上一个按钮点击事件就大功告成了。


浏览器兼容性处理


上面的实现是最完美的处理方式。但是,在Firefox浏览器却出现了兼容性问题。


image.png


哦豁。如此就非常尴尬。祸不单行,Safari浏览器也出现了兼容问题。


经过多番查证,发现是display: -webkit-box;属性存在兼容问题。


问题就在于,如果没有display: -webkit-box;怎么实现多行截断呢?如果在知道行数的情况下设置一个最大高度,理论上也能实现多行截断。由此我们通过行高属性line-height去入手。如果需要设置成 3 行,那就将高度设置成为 line-height * 3。


.more-text {
/*
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
*/
overflow: hidden;
line-height: 1.5;
max-height: 4.5em;
}

image.png


此时呢还缺少省略号...。可以利用伪元素实现。


.more-btn::before{
content: '…';
color: #333;
font-size: 14px;
position: absolute;
left: -10px;
transform: translateX(-100%);
}

image.png


大功告成,接下来加上点击切换即可。


点击切换“展开“ 与 ”收起“。


咱们目标是纯CSS完成。那么CSS状态切换就必不可少了,完全可以用input type = "checkbox"这个特性来完成。


要用到input特性就得对html代码进行一些修改。


<div class="more-wrapper">
<input type="checkbox" id="exp" />
<div class="more-text">
<!-- <div>展开</div> -->
<label class="more-btn" for="exp">展开</label>
如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。
</div>
</div>

#exp:checked + .more-text {
-webkit-line-clamp: 999;
max-height: none;
}

more.gif


接下来,就是变换按钮文字,以及展开之后省略号隐藏。此时都可以利用伪元素处理。


<label class="more-btn" for="exp"></label>
<!-- 去掉按钮文字 -->

.more-btn::after {
content: '更多';
}

在:checked状态中


#exp:checked + .more-text .more-btn::after {
content: '收起';
}

省略号隐藏处理。


#exp:checked + .more-text .more-btn::before {
visibility: hidden;
}

more.gif


至此,我们需要的效果便成了。


当然咱们还可以添加一些过渡动画让展开收起效果更加美观。在此就不演示了。


最后,文本行数判断


此前的步骤已经能够满足使用需求。但是还是存在问题。比如当文本内容较少时,此时不会发生截断,便不需要省略号...以及展开收起按钮。


image.png


此时当然可以选择js方式去做判断。但我们的目标是纯CSS。


那CSS没有逻辑判断,咱们只能另辟蹊径,视觉欺骗。或者叫做障眼法


比如在上图中的场景,没有发生截断,那就不需要省略号...展开按钮。这时,如果在文本的最后加上一个元素。并且为了不影响布局,给此元素设置绝对定位。


.more-text::after {
content: '';
width: 100%;
height: 100%;
position: absolute;
background: red;
}

同时,我们把父级的overflow: hidden;先去掉。得到效果如下


image.png


如图可见,红色部分的元素非常完美的挡住了按钮部分。


那我们把红色改成父级一样的背景色,并且恢复父级的overflow: hidden;


more.gif


上图可见,发现展开之后呢,伪元素盖住了收起按钮。所以必须再做一些修改。


#exp:checked + .more-text::after {
visibility: hidden;
}

more.gif


如你所见,非~常的好用。



注:IE10以下就不考虑了哈~




链接:https://juejin.cn/post/7007632958622269471

收起阅读 »

浅谈前端的状态管理

前言 提到状态管理大家可能马上就想到:Vuex、Redux、Flux、Mobx等等方案。其实不然,不论哪种方案只要内容一多起来似乎都是令人头疼的问题,也许你有适合自己的解决方案又或者简单的注释和区分模块,今天来聊一聊前端的状态管理,如果你有好的建议或问题欢迎在...
继续阅读 »

前言


提到状态管理大家可能马上就想到:Vuex、Redux、Flux、Mobx等等方案。其实不然,不论哪种方案只要内容一多起来似乎都是令人头疼的问题,也许你有适合自己的解决方案又或者简单的注释和区分模块,今天来聊一聊前端的状态管理,如果你有好的建议或问题欢迎在下方留言提出。


什么是前端状态管理?


举个例子:图书馆里所有人都可以随意进书库借书还书,如果人数不多,这种方式可以提高效率减少流程,一旦人数多起来就容易混乱,书的走向不明确,甚至丢失。所以需要一个图书管理员来专门记录借书的记录,也就是你要委托图书管理员给你借书及还书。


实际上,大多数状态管理方案都是如上思想,通过管理员(比如 Vuex)去规范书库里书本的借还(项目中需要存储的数据)


Vuex


在国内业务使用中 Vuex 的比例应该是最高的,Vuex 也是基于 Flux 思想的产品,Vuex 中的 state 是可以被修改的。原因和 Vue 的运行机制有关系,Vue 基于 ES5 中的 getter/setter 来实现视图和数据的双向绑定,因此 Vuex 中 state 的变更可以通过 setter 通知到视图中对应的指令来实现视图更新。更改 Vuex 的 store 中的状态的唯一方法是提交 mutation。我们以图书馆来作为例子:


const state = {
book: 0
}

const mutations = {
borrow_book(state) {
state.book ++
}
}

//调用时
store.commit('borrow_book')

那还有action呢? 在 mutation 中混合异步调用会导致你的程序很难调试。你怎么知道是哪个先执行完呢?
aciton 可以包含任意异步操作,用法跟上面基本类似,不再叙述。


其实我只是拿 Vuex 来浅入一下相关用法大家应该是都熟悉了,那 Vuex 解决了什么问题呢?



  • 管理多个组件共享状态。

  • 全局状态管理。

  • 状态变更跟踪。

  • 让状态管理形成一种规范,使代码结构更清晰。


实际上大部分程序员都比较懒(狗头保命),只是为了能多个组件共享状态,至于其他的都是事后了。最典型的就是加入购物车的数量,加入一个就通过 Vuex 记录保存最终的总数显示在下栏。


那问题来了,既然你的目的只是共享多个状态,那何不直接用 Bus 总线好了?


Bus 总线


Bus 总线实际上他是一个公共的 Vue 实例,专门处理 emit 和 on 事件。


实际上 Bus 总线十分轻便,他并不存在 Dom 结构,他仅仅只是具有实例方法而已。


Vue.prototype.$Bus = new Vue()

然后,你可以通过 emit 来发送事件, on 来接收事件。


// 发送事件
this.$Bus.$emit('borrow_book', 1)

// 任意组件中接收
this.$Bus.$on('borrow_book', (book) => {
console.log(`借了${book}本书`)
})

当然还有 off(移除)、once(监听一次)等操作感兴趣可以自行搜索引擎。


怎么样?上面对于满足共享一个状态是不是比 Vuex 要简单多了?实际上确实是简单多了,但这也代表他比较适合中小型项目。多于大型项目来说 Bus 只会让你追述更改源时一脸懵逼甚至你都不知道他在哪里改变了。


他的工作原理就是发布订阅者的思想,虽然非常优雅简单,但实际 Vue 并不提倡这种写法,并在3.0版本中移除了大部分相关Api(emit、on等),其实不然,发布订阅模式你也可以自己手写一个去实现:


class Bus {
constructor() {
// 收集订阅信息,调度中心
this.list = {};
}

// 订阅
$on(name, fn) {
this.list[name] = this.list[name] || [];
this.list[name].push(fn);
}

// 发布
$emit(name, data) {
if (this.list[name]) {
this.list[name].forEach((fn) => {
fn(data);
});
}
}

// 取消订阅
$off(name) {
if (this.list[name]) {
delete this.list[name];
}
}
}
export default Bus;

简单吧?你只需要跟用 Vue Bus 一样去实例化然后用就可以了。什么?你想共享两三个甚至更少的状态(一个),那封装一个 Bus 是不是有点没必要了? 行吧,那你用 web storage 吧。


web storage


其实说到这,storage只是数据存储方式,跟状态管理其实没有太大关系,只是共享数据。但是既然都提到了那就顺带说一下(狗头)


web storage 有这三种:cookie、local storage、session storage。


无论这三种的哪种都强烈建议不要将敏感信息放入其中,这里应该是加密或一些不那么重要的数据在里面。


先简单复习一下三者:































类别生命周期存储容量存储位置
cookie默认保存在内存中,随浏览器关闭失效(如果设置过期时间,在到过期时间后失效)4KB保存在客户端,每次请求时都会带上
localStorage理论上永久有效的,除非主动清除。4.98MB(不同浏览器情况不同,safari 2.49M)保存在客户端,不与服务端交互。节省网络流量
sessionStorage仅在当前网页会话下有效,关闭页面或浏览器后会被清除。4.98MB(部分浏览器没有限制)同上

cookie 不必多说,大家发起请求时经常会携带cookie请求一些个人数据等,与我们要探讨的内容没有太大关系。


loaclStorage 可以存储理论上永久有效的数据,如果你要存储状态一般推荐是放在 sessionStorage,localStorage 也有以下局限:



  • 浏览器的大小不统一,并且在 IE8 以上的 IE 版本才支持 localStorage 这个属性。

  • 目前所有的浏览器中都会把localStorage的值类型限定为string类型,这个在对我们日常比较常见的JSON对象类型需要一些转换。

  • localStorage在浏览器的隐私模式下面是不可读取的。

  • localStorage本质上是对字符串的读取,如果存储内容多的话会消耗内存空间,会导致页面变卡。

  • localStorage不能被爬虫抓取到。


localStorage 与 sessionStorage 的唯一一点区别就是 localStorage 属于永久性存储,而 sessionStorage 属于当会话结束的时候,sessionStorage 中的键值对会被清空。


localStorage 本身只支持字符串形式存储,所以你存整数类型,拿出来的会是字符串类型。


sessionStorage 与 localStorage 基本差不多,只是回话关闭时,数据就会清空。


总结


不论哪种方案选择合适自己项目的方案才是最佳实践。没有最好的方案,只有合适自己的方案。


以上只是略微浅谈,也可能不够全面,欢迎下方留言~


链接:https://juejin.cn/post/7007306391836688415

收起阅读 »

关注 ? ? ? 前端仔也需要懂的nginx内容

tips 如果你已经使用过nginx的,可以跳过介绍,直接看nginx配置文件和使用场景,如果你想全局熟悉下nginx,就耐心慢慢看看,在文章结尾会补上nginx的一些常用实战场景 前言 作为一名前端,我们除了node作为服务以外,我们还有什么选择,那么简单容...
继续阅读 »


tips


如果你已经使用过nginx的,可以跳过介绍,直接看nginx配置文件使用场景,如果你想全局熟悉下nginx,就耐心慢慢看看,在文章结尾会补上nginx的一些常用实战场景


前言


作为一名前端,我们除了node作为服务以外,我们还有什么选择,那么简单容易上手的Nginx可以满足你的一切幻想。学习nginx可以让我们更加清晰前端项目上线的整个流程

作为一个前端,或多或少都会对Nginx有一些经验,那为什么还要学习那? 不系统:以前可能你只会配置某项功能(网上搜集),都是碎片化的知识,不没有形成系统化。这样就导致你服务出现问题时,根本不知道从哪里下手来解决这些问题。


一、Nginx是什么?


nginx官方介绍:



"Nginx是一款轻量级的HTTP服务器,采用事件驱动的异步非阻塞处理方式框架,这让其具有极好的IO性能,时常用于服务端的反向代理和负载均衡。"



nginx的优点



  • 支持海量高并发:采用IO多路复用epoll。官方测试Nginx能够支持5万并发链接,实际生产环境中可以支撑2-4万并发连接数。

  • 内存消耗少

  • 可商业化

  • 配置文件简单


除了这些优点还有很多,比如反向代理功能,灰度发布,负载均衡功能等


二、安装


这里的文章不着重介绍怎么安装nginx,但是也给大家留下了安装的教程地址,自取



如果是centos大家也可以直接用yum安装也是很方便的


yum -y install nginx


nginx.conf 文件是nginx总配置文件也是nginx读取配置的入口。


三、nginx文件介绍


nginx我们最常用到的文件,其实就是nginx的配置文件,其他的文件我们就带过了,当你能熟练编写nginx文件,其实就等于熟练使用nginx了


[wujianrong@localhost ~]# tree /usr/local/nginx
/usr/local/nginx
├── client_body_temp
├── conf # Nginx所有配置文件的目录
│ ├── fastcgi.conf # fastcgi相关参数的配置文件
│ ├── fastcgi.conf.default # fastcgi.conf的原始备份文件
│ ├── fastcgi_params # fastcgi的参数文件
│ ├── fastcgi_params.default
│ ├── koi-utf
│ ├── koi-win
│ ├── mime.types # 媒体类型
│ ├── mime.types.default
│ ├── nginx.conf # Nginx主配置文件
│ ├── nginx.conf.default
│ ├── scgi_params # scgi相关参数文件
│ ├── scgi_params.default
│ ├── uwsgi_params # uwsgi相关参数文件
│ ├── uwsgi_params.default
│ └── win-utf
├── fastcgi_temp # fastcgi临时数据目录
├── html # Nginx默认站点目录
│ ├── 50x.html # 错误页面优雅替代显示文件,例如当出现502错误时会调用此页面
│ └── index.html # 默认的首页文件
├── logs # Nginx日志目录
│ ├── access.log # 访问日志文件
│ ├── error.log # 错误日志文件
│ └── nginx.pid # pid文件,Nginx进程启动后,会把所有进程的ID号写到此文件
├── proxy_temp # 临时目录
├── sbin # Nginx命令目录
│ └── nginx # Nginx的启动命令
├── scgi_temp # 临时目录
└── uwsgi_temp # 临时目录


1. 配置文件(重点)


conf //nginx所有配置文件目录   
nginx.conf //这个是Nginx的核心配置文件,这个文件非常重要,也是我们即将要学习的重点
nginx.conf.default //nginx.conf的备份文件

2. 日志


logs: 记录入门的文件,当nginx服务器启动后
这里面会有 access.log error.log 和nginx.pid三个文件出现。

3. 资源目录


html //存放nginx自带的两个静态的html页面   
50x.html //访问失败后的失败页面
index.html //成功访问的默认首页

4. 备份文件


fastcgi.conf:fastcgi  //相关配置文件
fastcgi.conf.default //fastcgi.conf的备份文件
fastcgi_params //fastcgi的参数文件
fastcgi_params.default //fastcgi的参数备份文件
scgi_params //scgi的参数文件
scgi_params.default //scgi的参数备份文件
uwsgi_params //uwsgi的参数文件
uwsgi_params.default //uwsgi的参数备份文件
mime.types //记录的是HTTP协议中的Content-Type的值和文件后缀名的对应关系
mime.types.default //mime.types的备份文件

5.编码文件


koi-utf、koi-win、win-utf这三个文件都是与编码转换映射相关的配置文件,
用来将一种编码转换成另一种编码

6. 执行文件


sbin: 是存放执行程序文件nginx

7. 命令


nginx: 是用来控制Nginx的启动和停止等相关的命令。

四、nginx常用命令



  1. 常见2种启动命令


> nginx //直接nginx启动,前提是配好nginx环境变量
> systemctl start nginx.service //使用systemctl命令启动


  1. 常见的4种停止命令


> nginx  -s stop //立即停止服务
> nginx -s quit // 从容停止服务 需要进程完成当前工作后再停止
> killall nginx //直接杀死nginx进程
> systemctl stop nginx.service //systemctl停止


  1. 常见的2种重启命令


> nginx -s reload //重启nginx
> systemctl reload nginx.service //systemctl重启nginx


  1. 验证nginx配置文件是否正确


> nginx -t //输出nginx.conf syntax is ok即表示nginx的配置文件正确


五、nginx配置详细介绍


1. 配置文件的结构介绍


为了让大家有个简单的轮廓,这里先对配置文件做一个简单的描述:


worker_processes  1;                			# worker进程的数量
events { # 事件区块开始
worker_connections 1024; # 每个worker进程支持的最大连接数
} # 事件区块结束
http { # HTTP区块开始
include mime.types; # Nginx支持的媒体类型库文件
default_type application/octet-stream; # 默认的媒体类型
sendfile on; # 开启高效传输模式
keepalive_timeout 65; # 连接超时
server { # 第一个Server区块开始,表示一个独立的虚拟主机站点
listen 80; # 提供服务的端口,默认80
server_name localhost; # 提供服务的域名主机名
location / { # 第一个location区块开始
root html; # 站点的根目录,相当于Nginx的安装目录
index index.html index.htm; # 默认的首页文件,多个用空格分开
} # 第一个location区块结果
error_page 500502503504 /50x.html; # 出现对应的http状态码时,使用50x.html回应客户
location = /50x.html { # location区块开始,访问50x.html
root html; # 指定对应的站点目录为html
}
}
......



  1. ngxin.conf 相当于是入口文件,nginx启动后会先从nginx.conf里面读取基础配置

  2. conf 目录下面的各种xxx.conf文件呢,一般就是每一个应用的配置,比如a网站的nginx配置叫a.conf,b网站的叫b.conf,可以方便我们去便于管理

  3. 加载conf目录下的配置,在主配置文件nginx.conf中,一般会有这么一行代码


2. nginx.conf主配置文件详细介绍


image.png


3. xx.conf 子配置文件详细介绍


我们最常改动nginx的,就是子配置文件


image.png


4. 关于location匹配


    #优先级1,精确匹配,根路径
location =/ {
return 400;
}

#优先级2,以某个字符串开头,以av开头的,优先匹配这里,区分大小写
location ^~ /av {
root /data/av/;
}

#优先级3,区分大小写的正则匹配,匹配/media*****路径
location ~ /media {
alias /data/static/;
}

#优先级4 ,不区分大小写的正则匹配,所有的****.jpg|gif|png 都走这里
location ~* .*\.(jpg|gif|png|js|css)$ {
root /data/av/;
}

#优先7,通用匹配
location / {
return 403;
}

更多配置


六、nginx反向代理、负载均衡 简单介绍


1. 反向代理


在聊反向代理之前,我们先看看正向代理,正向代理也是大家最常接触的到的代理模式,我们会从两个方面来说关于正向代理的处理模式,分别从软件方面和生活方面来解释一下什么叫正向代理,也说说正反向代理的区别


正向代理


正向代理,"它代理的是客户端",是一个位于客户端和原始服务器(origin server)之间的服务器,为了从原始服务器取得内容,客户端向代理发送一个请求并指定目标(原始服务器),然后代理向原始服务器转交请求并将获得的内容返回给客户端。客户端必须要进行一些特别的设置才能使用正向代理
正向代理的用途:



  • 访问原来无法访问的资源,如Google

  • 可以做缓存,加速访问资源

  • 对客户端访问授权,上网进行认证

  • 代理可以记录用户访问记录(上网行为管理),对外隐藏用户信息


反向代理


反向代理,"它代理的是服务端",主要用于服务器集群分布式部署的情况下,反向代理隐藏了服务器的信息
反向代理的作用:



  • 保证内网的安全,通常将反向代理作为公网访问地址,Web服务器是内网

  • 负载均衡,通过反向代理服务器来优化网站的负载


image.png


2. 负载均衡


服务器接收不同客户端发送的、Nginx反向代理服务器接收到的请求数量,就是我们说的负载量。
这些请求数量按照一定的规则进行分发到不同的服务器处理的规则,就是一种均衡规则。
所以,将服务器接收到的请求按照规则分发的过程,称为负载均衡

负载均衡也分硬件负载均衡和软件负载均衡两种,我们来讲的是软件负载均衡,关于硬件负载均衡的有兴趣的靓仔可以去了解下
负载均衡的算法:



  • 轮询(默认、加权轮询、ip_hash)

  • 插件(fair、url_hash),url_hash和ip_hash大同小异,一个基于ip一个基于url,就不过多介绍了


默认轮询


每个请求按时间顺序逐一分配到不同的后端服务器,如果后端某个服务器宕机,能自动剔除故障系统。


# constPolling 作为存放负载均衡的变量
upstream constPolling {
server localhost:10001;
server localhost:10002;
}
server {
listen 10000;
server_name localhost;
location / {
proxy_pass http://constPolling; #在代理的时候接入constPolling
proxy_redirect default;
}
}

加权轮询


通过设置weight,值越大分配率越大
到的访问概率越高,主要用于后端每台服务器性能不均衡的情况下。其次是为在主从的情况下设置不同的权值,达到合理有效的地利用主机资源。


# constPolling 作为存放负载均衡的变量
upstream constPolling {
server localhost:10001 weight=1;
server localhost:10002 weight=2;
}
server {
listen 10000;
server_name localhost;
location / {
proxy_pass http://constPolling; #在代理的时候接入constPolling
proxy_redirect default;
}
}

权重越大,被访问的概率越大,比如上面就是33.33%和百分66.66%的访问概率
访问的效果:

localhost:10001、localhost:10002、localhost:10002、localhost:10001、localhost:10002、localhost:10002


ip_hash


每个请求都根据访问ip的hash结果分配,经过这样的处理,每个访客固定访问一个后端服务,如下配置(ip_hash可以和weight配合使用),并且可以有效解决动态网页存在的session共享问题


upstream constPolling {
ip_hash;
server localhost:10001 weight=1;
server localhost:10002 weight=2;
}

fair


个人比较喜欢用的一种负载均衡算法,fair算法可以根据页面大小和加载时间长短智能地进行负载均衡,响应时间短的优先分配。



  1. 安装upstream_fair模块 附上fair安装教程

  2. 哪个服务器的响应速度快,就将请求分配到那个服务器上


upstream constPolling { 
server localhost:10001;
server localhost:10002;
fair;
}

七、nginx错误页面配置、开启Gzip压缩配置


1. nginx错误页面配置


当我们访问的地址不存在的时候,我们可以根据http状态码来做对应的处理,我们以404为例


image.png
当然除了404以为我们还可以根据其他的状态码显示的,比如500、502等,熊猫的公司项目中,因为多个项目的错误页面都是统一的,所以我们有单独维护的一套错误码页面放到了我们公司的中台项目中,然后根据客户端是PC/移动端,跳转到对应的错误页面


2.Gzip压缩


Gzip是网页的一种网页压缩技术,经过gzip压缩后,页面大小可以变为原来的30%甚至更小。更小的网页会让用户浏览的体验更好,速度更快。gzip网页压缩的实现需要浏览器和服务器的支持

gzip是需要服务器和浏览器同时支持的。当浏览器支持gzip压缩时,会在请求消息中包含Accept-Encoding:gzip,这样Nginx就会向浏览器发送听过gzip后的内容,同时在相应信息头中加入Content-Encoding:gzip,声明这是gzip后的内容,告知浏览器要先解压后才能解析输出。
如果项目是在ie或者一些兼容性比较低浏览器上运行的,需要去查阅确定是否浏览器支持gzip


server {

listen 12089;

index index.php index.html;

error_log /var/log/nginx/error.log;

access_log /var/log/nginx/access.log;

root /var/www/html/gzip;
# 开启gzip压缩

gzip on;

# http请求版本

gzip_http_version 1.0;

# 设置什么类型的文件需要压缩

gzip_types text/css text/javascript application/javascript image/png image/jpeg image/gif;

location / {

index index.html index.htm index.php;

autoindex off;

}

}

gzip_types对应需要什么格式,可以去查看content-Type


image.png


Content-Type: text/css

# 成功开启gzip
Content-Encoding: gzip

八、常用全局变量
































































































变量含义
$args这个变量等于请求行中的参数,同$query_string
$content length请求头中的Content-length字段。
$content_type请求头中的Content-Type字段。
$document_root当前请求在root指令中指定的值。
$host请求主机头字段,否则为服务器名称。
$http_user_agent客户端agent信息
$http_cookie客户端cookie信息
$limit_rate这个变量可以限制连接速率。
$request_method客户端请求的动作,通常为GET或POST。
$remote_addr客户端的IP地址。
$remote_port客户端的端口。
$remote_user已经经过Auth Basic Module验证的用户名。
$request_filename当前请求的文件路径,由root或alias指令与URI请求生成。
$schemeHTTP方法(如http,https)。
$server_protocol请求使用的协议,通常是HTTP/1.0或HTTP/1.1。
$server_addr服务器地址,在完成一次系统调用后可以确定这个值。
$server_name服务器名称。
$server_port请求到达服务器的端口号。
$request_uri包含请求参数的原始URI,不包含主机名,如”/foo/bar.php?arg=baz”。
$uri不带请求参数的当前URI,$uri不包含主机名,如”/foo/bar.html”。
$document_uri与$uri相同。



九、nginx使用综合场景(在github里面会持续更新和补充)


1. 同一个域名通过不同目录指定不同项目目录


在开发过程中,有一种场景,比如有项目有多个子系统需要通过同一个域名通过不同目录去访问
在A/B Test 灰度发布等场景也会用上

比如:

访问 a.com/a/*** 访问的是a系统

访问 a.com/b/*** 访问的是b系统


image.png


2. 自动适配PC/移动端页面


image.png


3. 限制只能通过谷歌浏览器访问


image.png


4. 前端单页面应用刷新404问题


image.png


更多:包括防盗链、动静分离、权限控制



链接:https://juejin.cn/post/7007346707767754765

收起阅读 »

从零开发一款轻量级滑动验证码插件

效果演示 滑动验证组件基本使用和技术实现 上图是实现的滑动验证组件的一个效果演示,当然还有很多配置项可以选择,以便支持更多 定制化 的场景。接下来我先介绍一下如何安装和使用这款验证码插件,让大家有一个直观的体验,然后我会详细介绍一下滑动验证码的实现思路,如果...
继续阅读 »

效果演示


slider.gif


滑动验证组件基本使用和技术实现


上图是实现的滑动验证组件的一个效果演示,当然还有很多配置项可以选择,以便支持更多 定制化 的场景。接下来我先介绍一下如何安装和使用这款验证码插件,让大家有一个直观的体验,然后我会详细介绍一下滑动验证码的实现思路,如果大家有一定的技术基础,也可以直接跳到技术实现部分。


基本使用


因为 react-slider-vertify 这款组件我已经发布到 npm 上了,所以大家可以按照如下方式安装和使用:



  1. 安装


# 或者 yarn add @alex_xu/react-slider-vertify
npm i @alex_xu/react-slider-vertify -S


  1. 使用


import React from 'react';
import { Vertify } from '@alex_xu/react-slider-vertify';

export default () => {
return <Vertify
width={320}
height={160}
onSuccess={() => alert('success')}
onFail={() => alert('fail')}
onRefresh={() => alert('refresh')}
/>
};

通过以上两步我们就可以轻松使用这款滑动验证码组件了,是不是很简单?
image.png


当然我也暴露了很多可配置的属性,让大家对组件有更好的控制。参考如下:


image.png


技术实现


在做这个项目之前我也研究了一些滑动验证码的知识以及已有的技术方案,收获很多。接下来我会以我的组件设计思路来和大家介绍如何用 react 来实现和封装滑动验证码组件,如果大家有更好的想法和建议, 也可以在评论区随时和我反馈。


1.组件设计的思路和技巧


每个人都有自己设计组件的方式和风格,但最终目的都是更 优雅 的设计组件。这里我大致列举一下 优雅 组件的设计指标:




  • 可读性(代码格式统一清晰,注释完整,代码结构层次分明,编程范式使用得当)




  • 可用性(代码功能完整,在不同场景都能很好兼容,业务逻辑覆盖率)




  • 复用性(代码可以很好的被其他业务模块复用)




  • 可维护性(代码易于维护和扩展,并有一定的向下/向上兼容性)




  • 高性能




以上是我自己设计组件的考量指标,大家可以参考一下。


另外设计组件之前我们还需要明确需求,就拿滑动验证码组件举例,我们需要先知道它的使用场景(用于登录注册、活动、论坛、短信等高风险业务场景的人机验证服务)和需求(交互逻辑,以什么样的方式验证,需要暴露哪些属性)。


image.png


以上就是我梳理的一个大致的组件开发需求,在开发具体组件之前,如果遇到复杂的业务逻辑,我们还可以将每一个实现步骤列举出来,然后一一实现,这样有助于整理我们的思路和更高效的开发。


2.滑动验证码基本实现原理


在介绍完组件设计思路和需求分析之后,我们来看看滑动验证码的实现原理。


image.png


我们都知道设计验证码的主要目的是为了防止机器非法暴力地入侵我们的应用,其中核心要解决的问题就是判断应用是谁在操作( or 机器),所以通常的解决方案就是随机识别


上图我们可以看到只有用户手动将滑块拖拽到对应的镂空区域,才算验证成功,镂空区域的位置是随机的(随机性测试这里暂时以前端的方式来实现,更安全的做法是通过后端来返回位置和图片)。


基于以上分析我们就可以得出一个基本的滑动验证码设计原理图:


image.png


接下来我们就一起封装这款可扩展的滑动验证码组件。


3.封装一款可扩展的滑动验证码组件


按照我开发组件一贯的风格,我会先基于需求来编写组件的基本框架:


import React, { useRef, useState, useEffect, ReactNode } from 'react';

interface IVertifyProp {
/**
* @description canvas宽度
* @default 320
*/
width:number,
/**
* @description canvas高度
* @default 160
*/
height:number,
/**
* @description 滑块边长
* @default 42
*/
l:number,
/**
* @description 滑块半径
* @default 9
*/
r:number,
/**
* @description 是否可见
* @default true
*/
visible:boolean,
/**
* @description 滑块文本
* @default 向右滑动填充拼图
*/
text:string | ReactNode,
/**
* @description 刷新按钮icon, 为icon的url地址
* @default -
*/
refreshIcon:string,
/**
* @description 用于获取随机图片的url地址
* @default https://picsum.photos/${id}/${width}/${height}, 具体参考https://picsum.photos/, 只需要实现类似接口即可
*/
imgUrl:string,
/**
* @description 验证成功回调
* @default ():void => {}
*/
onSuccess:VoidFunction,
/**
* @description 验证失败回调
* @default ():void => {}
*/
onFail:VoidFunction,
/**
* @description 刷新时回调
* @default ():void => {}
*/
onRefresh:VoidFunction
}

export default ({
width = 320,
height = 160,
l = 42,
r = 9,
imgUrl,
text,
refreshIcon = 'http://yourimgsite/icon.png',
visible = true,
onSuccess,
onFail,
onRefresh
}: IVertifyProp) => {
return <div className="vertifyWrap">
<div className="canvasArea">
<canvas width={width} height={height}></canvas>
<canvas className="block" width={width} height={height}></canvas>
</div>
<div className={sliderClass}>
<div className="sliderMask">
<div className="slider">
<div className="sliderIcon">&rarr;</div>
</div>
</div>
<div className="sliderText">{ textTip }</div>
</div>
<div className="refreshIcon" onClick={handleRefresh}></div>
<div className="loadingContainer">
<div className="loadingIcon"></div>
<span>加载中...</span>
</div>
</div>
}

以上就是我们组件的基本框架结构。从代码中可以发现组件属性一目了然,这都是提前做好需求整理带来的好处,它可以让我们在编写组件时思路更清晰。在编写好基本的 css 样式之后我们看到的界面是这样的:


image.png


接下来我们需要实现以下几个核心功能:



  • 镂空效果的 canvas 图片实现

  • 镂空图案 canvas 实现

  • 滑块移动和验证逻辑实现


上面的描述可能比较抽象,我画张图示意一下:


image.png


因为组件实现完全采用的 react hooks ,如果大家对 hooks 不熟悉也可以参考我之前的文章:



1.实现镂空效果的 canvas 图片


image.png


在开始 coding 之前我们需要对 canvas 有个基本的了解,建议不熟悉的朋友可以参考高效 canvas 学习文档: Canvas of MDN


由上图可知首先要解决的问题就是如何用 canvas 画不规则的图形,这里我简单的画个草图:


image.png


我们只需要使用 canvas 提供的 路径api 画出上图的路径,并将路径填充为任意半透明的颜色即可。建议大家不熟悉的可以先了解如下 api :



  • beginPath() 开始路径绘制

  • moveTo() 移动笔触到指定点

  • arc() 绘制弧形

  • lineTo() 画线

  • stroke() 描边

  • fill() 填充

  • clip() 裁切路径


实现方法如下:


const drawPath  = (ctx:any, x:number, y:number, operation: 'fill' | 'clip') => {
ctx.beginPath()
ctx.moveTo(x, y)
ctx.arc(x + l / 2, y - r + 2, r, 0.72 * PI, 2.26 * PI)
ctx.lineTo(x + l, y)
ctx.arc(x + l + r - 2, y + l / 2, r, 1.21 * PI, 2.78 * PI)
ctx.lineTo(x + l, y + l)
ctx.lineTo(x, y + l)
// anticlockwise为一个布尔值。为true时,是逆时针方向,否则顺时针方向
ctx.arc(x + r - 2, y + l / 2, r + 0.4, 2.76 * PI, 1.24 * PI, true)
ctx.lineTo(x, y)
ctx.lineWidth = 2
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'
ctx.stroke()
ctx.globalCompositeOperation = 'destination-over'
// 判断是填充还是裁切, 裁切主要用于生成图案滑块
operation === 'fill'? ctx.fill() : ctx.clip()
}

这块实现方案也是参考了 yield 大佬的原生 js 实现,这里需要补充的一点是 canvasglobalCompositeOperation 属性,它的主要目的是设置如何将一个源(新的)图像绘制到目标(已有)的图像上。




  • 源图像 = 我们打算放置到画布上的绘图




  • 目标图像 = 我们已经放置在画布上的绘图




w3c上有个形象的例子:


image.png


这里之所以设置该属性是为了让镂空的形状不受背景底图的影响并覆盖在背景底图的上方。如下:


image.png


接下来我们只需要将图片绘制到画布上即可:


const canvasCtx = canvasRef.current.getContext('2d')
// 绘制镂空形状
drawPath(canvasCtx, 50, 50, 'fill')

// 画入图片
canvasCtx.drawImage(img, 0, 0, width, height)

当然至于如何生成随机图片和随机位置,实现方式也很简单,前端实现的话采用 Math.random 即可。


2.实现镂空图案 canvas


上面实现了镂空形状,那么镂空图案也类似,我们只需要使用 clip() 方法将图片裁切到形状遮罩里,并将镂空图案置于画布左边即可。代码如下:


const blockCtx = blockRef.current.getContext('2d')
drawPath(blockCtx, 50, 50, 'clip')
blockCtx.drawImage(img, 0, 0, width, height)

// 提取图案滑块并放到最左边
const y1 = 50 - r * 2 - 1
const ImageData = blockCtx.getImageData(xRef.current - 3, y1, L, L)
// 调整滑块画布宽度
blockRef.current.width = L
blockCtx.putImageData(ImageData, 0, y1)

上面的代码我们用到了 getImageDataputImageData,这两个 api 主要用来获取 canvas 画布场景像素数据和对场景进行像素数据的写入。实现后 的效果如下:


image.png


3.实现滑块移动和验证逻辑


实现滑块移动的方案也比较简单,我们只需要利用鼠标的 event 事件即可:



  • onMouseDown

  • onMouseMove

  • onMouseUp


image.png


以上是一个简单的示意图,具体实现代码如下:


const handleDragMove = (e) => {
if (!isMouseDownRef.current) return false
e.preventDefault()
// 为了支持移动端, 可以使用e.touches[0]
const eventX = e.clientX || e.touches[0].clientX
const eventY = e.clientY || e.touches[0].clientY
const moveX = eventX - originXRef.current
const moveY = eventY - originYRef.current
if (moveX < 0 || moveX + 36 >= width) return false
setSliderLeft(moveX)
const blockLeft = (width - l - 2r) / (width - l) * moveX
blockRef.current.style.left = blockLeft + 'px'
}

当然我们还需要对拖拽停止后的事件做监听,来判断是否验证成功,并埋入成功和失败的回调。代码如下:


const handleDragEnd = (e) => {
if (!isMouseDownRef.current) return false
isMouseDownRef.current = false
const eventX = e.clientX || e.changedTouches[0].clientX
if (eventX === originXRef.current) return false
setSliderClass('sliderContainer')
const { flag, result } = verify()
if (flag) {
if (result) {
setSliderClass('sliderContainer sliderContainer_success')
// 成功后的自定义回调函数
typeof onSuccess === 'function' && onSuccess()
} else {
// 验证失败, 刷新重置
setSliderClass('sliderContainer sliderContainer_fail')
setTextTip('请再试一次')
reset()
}
} else {
setSliderClass('sliderContainer sliderContainer_fail')
// 失败后的自定义回调函数
typeof onFail === 'function' && onFail()
setTimeout(reset.bind(this), 1000)
}
}

实现后的效果如下:


chrome-capture (4).gif


当然还有一些细节需要优化处理,这里在 github 上有完整的代码,大家可以参考学习一下,如果大家想对该组件参与贡献,也可以随时提 issue


4.如何使用 dumi 搭建组件文档


为了让组件能被其他人更好的理解和使用,我们可以搭建组件文档。作为一名热爱开源的前端 coder,编写组件文档也是个很好的开发习惯。接下来我们也为 react-slider-vertify 编写一下组件文档,这里我使用 dumi 来搭建组件文档,当然大家也可以用其他方案(比如storybook)。我们先看一下搭建后的效果:


image.png


image.png


dumi 搭建组件文档非常简单,接下来和大家介绍一下安装使用方式。



  1. 安装


$ npx @umijs/create-dumi-lib        # 初始化一个文档模式的组件库开发脚手架
# or
$ yarn create @umijs/dumi-lib

$ npx @umijs/create-dumi-lib --site # 初始化一个站点模式的组件库开发脚手架
# or
$ yarn create @umijs/dumi-lib --site


  1. 本地运行


npm run dev
# or
yarn dev


  1. 编写文档


dumi 约定式的定义了文档编写的位置和方式,其官网上也有具体的饭介绍,这里简单给大家上一个 dumi 搭建的组件目录结构图:


image.png


我们可以在 docs 下编写组件库文档首页和引导页的说明,在单个组件的文件夹下使用 index.md 来编写组件自身的使用文档,当然整个过程非常简单,我这里举一个文档的例子:


image.png


通过这种方式 dumi 就可以帮我们自动渲染一个组件使用文档。如果大家想学习更多组件文档搭建的内容,也可以在 dumi 官网学习。


5.发布自己第一个npm组件包


最后一个问题就是组件发布。之前很多朋友问我如何将自己的组件发布到 npm 上让更多人使用,这块的知识网上有很多资料可以学习,那今天就以滑动验证码 @alex_xu/react-slider-vertify 的例子,来和大家做一个简单的介绍。



  1. 拥有一个 npm 账号并登录


如果大家之前没有 npm 账号,可以在 npm 官网 注册一个,然后用我们熟悉的 IDE 终端登录一次:


npm login

跟着提示输入完用户名密码之后我们就能通过命令行发布组件包了:


npm publish --access public

之所以指令后面会加 public 参数,是为了避免权限问题导致组件包无法发布成功。我们为了省事也可以把发布命令配置到 package.json 中,在组件打包完成后自动发布:


{
"scripts": {
"start": "dumi dev",
"release": "npm run build && npm publish --access public",
}
}

这样我们就能将组件轻松发布到 npm 上供他人使用啦! 我之前也开源了很多组件库,如果大家对组件打包细节和构建流程有疑问,也可以参考我之前开源项目的方案。 发布到 npm 后的效果:


image.png


最后


如果大家对可视化搭建或者低代码/零代码感兴趣,也可以参考我往期的文章或者在评论区交流你的想法和心得,欢迎一起探索前端真正的技术。


链接:https://juejin.cn/post/7007615666609979400

收起阅读 »

Flutter自适应瀑布流

前言:在电商app经常会看到首页商品推荐的瀑布流,或者类似短视频app首页也是瀑布流,这些都是需要自适应的,才能给用户带来好的体验 话不多说先上效果图: 根据效果图可以分为四步: 1.图片自适应 2.自适应标签 3.上拉刷新和下拉加载 4.底部的点赞按钮可以...
继续阅读 »

前言:在电商app经常会看到首页商品推荐的瀑布流,或者类似短视频app首页也是瀑布流,这些都是需要自适应的,才能给用户带来好的体验


话不多说先上效果图:


在这里插入图片描述在这里插入图片描述


根据效果图可以分为四步:


1.图片自适应

2.自适应标签

3.上拉刷新和下拉加载

4.底部的点赞按钮可以去掉或者自己修改样式,我这里使用的like_button库

注:本文使用的库:为啥这么多呢,因为我把图片缓存这样东西都加上了,单纯的瀑布流就用waterfall_flow

waterfall_flow: ^3.0.1
extended_image: any
extended_sliver: any
ff_annotation_route_library: any
http_client_helper: any
intl: any
like_button: any
loading_more_list: any
pull_to_refresh_notification: any
url_launcher: any

1.图片自适应:


Widget image = Stack(
children: <Widget>[
ExtendedImage.network(
item.imageUrl,
shape: BoxShape.rectangle,
//clearMemoryCacheWhenDispose: true,
border: Border.all(color: Colors.grey.withOpacity(0.4), width: 1.0),
borderRadius: const BorderRadius.all(
Radius.circular(10.0),
),
loadStateChanged: (ExtendedImageState value) {
if (value.extendedImageLoadState == LoadState.loading) {
Widget loadingWidget = Container(
alignment: Alignment.center,
color: Colors.grey.withOpacity(0.8),
child: CircularProgressIndicator(
strokeWidth: 2.0,
valueColor:
AlwaysStoppedAnimation<Color>(Theme.of(c).primaryColor),
),
);
if (!konwSized) {
//todo: not work in web
loadingWidget = AspectRatio(
aspectRatio: 1.0,
child: loadingWidget,
);
}
return loadingWidget;
} else if (value.extendedImageLoadState == LoadState.completed) {
item.imageRawSize = Size(
value.extendedImageInfo.image.width.toDouble(),
value.extendedImageInfo.image.height.toDouble());
}
return null;
},
),
Positioned(
top: 5.0,
right: 5.0,
child: Container(
padding: const EdgeInsets.all(3.0),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.6),
border: Border.all(color: Colors.grey.withOpacity(0.4), width: 1.0),
borderRadius: const BorderRadius.all(
Radius.circular(5.0),
),
),
child: Text(
'${index + 1}',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: fontSize, color: Colors.white),
),
),
)
],
);
if (konwSized) {
image = AspectRatio(
aspectRatio: item.imageSize.width / item.imageSize.height,
child: image,
);
} else if (item.imageRawSize != null) {
image = AspectRatio(
aspectRatio: item.imageRawSize.width / item.imageRawSize.height,
child: image,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
image,
const SizedBox(
height: 5.0,
),
buildTagsWidget(item),
const SizedBox(
height: 5.0,
),
buildBottomWidget(item),
],
);
}

2.自适应标签:


Widget buildTagsWidget(
TuChongItem item, {
int maxNum = 6,
}) {
const double fontSize = 12.0;
return Wrap(
runSpacing: 5.0,
spacing: 5.0,
children: item.tags.take(maxNum).map<Widget>((String tag) {
final Color color = item.tagColors[item.tags.indexOf(tag)];
return Container(
padding: const EdgeInsets.all(3.0),
decoration: BoxDecoration(
color: color,
border: Border.all(color: Colors.grey.withOpacity(0.4), width: 1.0),
borderRadius: const BorderRadius.all(
Radius.circular(5.0),
),
),
child: Text(
tag,
textAlign: TextAlign.start,
style: TextStyle(
fontSize: fontSize,
color: color.computeLuminance() < 0.5
? Colors.white
: Colors.black),
),
);
}).toList());
}

3.上拉刷新和下拉加载


class PullToRefreshHeader extends StatelessWidget {
const PullToRefreshHeader(this.info, this.lastRefreshTime, {this.color});
final PullToRefreshScrollNotificationInfo info;
final DateTime lastRefreshTime;
final Color color;
@override
Widget build(BuildContext context) {
if (info == null) {
return Container();
}
String text = '';
if (info.mode == RefreshIndicatorMode.armed) {
text = 'Release to refresh';
} else if (info.mode == RefreshIndicatorMode.refresh ||
info.mode == RefreshIndicatorMode.snap) {
text = 'Loading...';
} else if (info.mode == RefreshIndicatorMode.done) {
text = 'Refresh completed.';
} else if (info.mode == RefreshIndicatorMode.drag) {
text = 'Pull to refresh';
} else if (info.mode == RefreshIndicatorMode.canceled) {
text = 'Cancel refresh';
}

final TextStyle ts = const TextStyle(
color: Colors.grey,
).copyWith(fontSize: 13);

final double dragOffset = info?.dragOffset ?? 0.0;

final DateTime time = lastRefreshTime ?? DateTime.now();
final double top = -hideHeight + dragOffset;
return Container(
height: dragOffset,
color: color ?? Colors.transparent,
//padding: EdgeInsets.only(top: dragOffset / 3),
//padding: EdgeInsets.only(bottom: 5.0),
child: Stack(
children: <Widget>[
Positioned(
left: 0.0,
right: 0.0,
top: top,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Expanded(
child: Container(
alignment: Alignment.centerRight,
child: RefreshImage(top),
margin: const EdgeInsets.only(right: 12.0),
),
),
Column(
children: <Widget>[
Text(
text,
style: ts,
),
Text(
'Last updated:' +
DateFormat('yyyy-MM-dd hh:mm').format(time),
style: ts.copyWith(fontSize: 12),
)
],
),
Expanded(
child: Container(),
),
],
),
)
],
),
);
}
}

class RefreshImage extends StatelessWidget {
const RefreshImage(this.top);
final double top;
@override
Widget build(BuildContext context) {
const double imageSize = 40;
return ExtendedImage.asset(
Assets.assets_fluttercandies_grey_png,
width: imageSize,
height: imageSize,
afterPaintImage: (Canvas canvas, Rect rect, ui.Image image, Paint paint) {
final double imageHeight = image.height.toDouble();
final double imageWidth = image.width.toDouble();
final Size size = rect.size;
final double y = (1 - min(top / (refreshHeight - hideHeight), 1)) *
imageHeight;

canvas.drawImageRect(
image,
Rect.fromLTWH(0.0, y, imageWidth, imageHeight - y),
Rect.fromLTWH(rect.left, rect.top + y / imageHeight * size.height,
size.width, (imageHeight - y) / imageHeight * size.height),
Paint()
..colorFilter =
const ColorFilter.mode(Color(0xFFea5504), BlendMode.srcIn)
..isAntiAlias = false
..filterQuality = FilterQuality.low);

//canvas.restore();
},
);
}
}

4.底部的点赞按钮


LikeButton(
size: 18.0,
isLiked: item.isFavorite,
likeCount: item.favorites,
countBuilder: (int count, bool isLiked, String text) {
final ColorSwatch<int> color =
isLiked ? Colors.pinkAccent : Colors.grey;
Widget result;
if (count == 0) {
result = Text(
'love',
style: TextStyle(color: color, fontSize: fontSize),
);
} else {
result = Text(
count >= 1000 ? (count / 1000.0).toStringAsFixed(1) + 'k' : text,
style: TextStyle(color: color, fontSize: fontSize),
);
}
return result;
},
likeCountAnimationType: item.favorites < 1000
? LikeCountAnimationType.part
: LikeCountAnimationType.none,
onTap: (bool isLiked) {
return onLikeButtonTap(isLiked, item);
},
)

这样自适应的瀑布流就完成了。


作者:阿Tya
链接:https://juejin.cn/post/7006876169471524901
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android 控制 ContentProvider的创建

序言 随着app隐私政策的收紧,现在不经过用户同意,就收集敏感信息的行为一旦被检测出来。很容易造成app下架。但是有些SDK的初始化是通过注册ContentProvider实现自动调用其onCreate()方法,来实现无感初始化的。如果SDK在ContentP...
继续阅读 »

序言


随着app隐私政策的收紧,现在不经过用户同意,就收集敏感信息的行为一旦被检测出来。很容易造成app下架。但是有些SDK的初始化是通过注册ContentProvider实现自动调用其onCreate()方法,来实现无感初始化的。如果SDK在ContentProvider中获取了敏感信息,又没有提供控制方法。我们就很被动。于是我花了点时间研究了怎么hook contentProvider的创建。让其在用户同意后再初始化。


方案1


声明在清单文件中的ContentProvider 会在应用启动后就创建。具体是在 ActivityThread的handleBindApplication方法中。(以下截图为Android 30的ActivityThread)
在这里插入图片描述
具体就在这一句
在这里插入图片描述


installContentProviders实现如下
在这里插入图片描述
最终是通过AppComponentFactory的instantiateProvider方法创建。
在这里插入图片描述
在这里插入图片描述


而AppComponentFactory是Android 28以后系统提供给我们的一个hook的工厂类。可以通过清单文件指定,在这里面可以hook 所有组件的初始化。
在这里插入图片描述
这么指定
在这里插入图片描述


但是在Android 28以下,比如这个截图是Android 25.没有这类,ContentProvider直接通过反射获得。无法通过该类来修改。
在这里插入图片描述


最终方案


为了兼容性,考虑如下方案。在调用installContentProviders前,如果这个data里面的providers为空岂不是不会走installContentProviders方法了吗。
在这里插入图片描述
这个data 是一个AppBindData类型,通过handleBindApplication方法的参数传入。会保存到ActivityThread的 mBoundApplication 字段中。
在这里插入图片描述


于是就可以通过获取这个mBoundApplication 字段中的providers 来保存要初始化的provider。再讲providers置为空即可。到了用户同意以后,再去通过反射调用ActivityThread的installContentProviders方法即可。
在这里插入图片描述


hook时机


这个时机只有Application的attachBaseContext方法中。该方法会比installContentProviders提前执行。


最后的代码App中


public class MyApp extends Application {

static MyApp app;

/**
*用户同意
*/
public static void agree(Action action) {
HookUtil.initProvider(app);
action.doAction();
}

public interface Action {
void doAction();
}


@Override
protected void attachBaseContext(Context base) {
app = this;
try {
HookUtil.attachContext();
} catch (Exception e) {
e.printStackTrace();
}
super.attachBaseContext(base);
}
}


HookUtil


package com.zgh.testcontentprovider;

import android.content.Context;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.List;


/**
* Created by zhuguohui
* Date: 2021/9/13
* Time: 11:23
* Desc:
*/
public class HookUtil {

private static Object providers;

private static Method installContentProvidersMethod;
private static Object currentActivityThread;

/*
*用户同意后调用
*/
public static void initProvider(Context context){
try {
installContentProvidersMethod.invoke(currentActivityThread,context,providers);
} catch (Exception e) {
e.printStackTrace();
}
}

public static void attachContext() throws Exception {

// 先获取到当前的ActivityThread对象
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
currentActivityThreadMethod.setAccessible(true);
//currentActivityThread是一个static函数所以可以直接invoke,不需要带实例参数
currentActivityThread = currentActivityThreadMethod.invoke(null);

hookInstallContentProvider(activityThreadClass);

}

private static void hookInstallContentProvider(Class activityThreadClass) throws Exception{
Field appDataField = activityThreadClass.getDeclaredField("mBoundApplication");
appDataField.setAccessible(true);
Object appData= appDataField.get(currentActivityThread);
Field providersField= appData.getClass().getDeclaredField("providers");
providersField.setAccessible(true);
providers = providersField.get(appData);
//清空provider,避免有些sdk通过provider来初始化
providersField.set(appData,null);

installContentProvidersMethod = activityThreadClass.getDeclaredMethod("installContentProviders", Context.class, List.class);
installContentProvidersMethod.setAccessible(true);
}
}


搭配


搭配我之前写的工具,可以更完美的实现用户同意之前不初始化任何SDK的目标
通过拦截 Activity的创建 实现APP的隐私政策改造


作者:solo_99
链接:https://juejin.cn/post/7007338307075964942
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

不做跟风党,LiveData,StateFlow,SharedFlow 使用场景对比

Android 常用的分层架构 Android 中加载 UI 数据不是一件轻松的事,开发者经常需要处理各种边界情况。如各种生命周期和因为「配置更改」导致的 Activity 的销毁与重建。 「配置更改」的场景有很多:屏幕旋转,切换至多窗口模式,调整窗口大小...
继续阅读 »

Android 常用的分层架构


Android 中加载 UI 数据不是一件轻松的事,开发者经常需要处理各种边界情况。如各种生命周期和因为「配置更改」导致的 Activity 的销毁与重建。



「配置更改」的场景有很多:屏幕旋转,切换至多窗口模式,调整窗口大小,浅色模式与暗黑模式的切换,更改默认语言,更改字体大小等等



因此普遍处理方式是使用分层的架构。这样开发者就可以编写独立于 UI 的代码,而无需过多考虑生命周期,配置更改等场景。 例如,我们可以在表现层(Presentation Layer)的基础上添加一个领域层(Domain Layer) 来保存业务逻辑,使用数据层(Data Layer)对上层屏蔽数据来源(数据可能来自远程服务,可能是本地数据库)。



表现层可以分成具有不同职责的组件:



  • View:处理生命周期回调,用户事件和页面跳转,Android 中主要是 Activity 和 Fragment

  • Presenter 或 ViewModel:向 View 提供数据,并不了解 View 所处的生命周期,通常生命周期比 View 长


Presenter 和 ViewModel 向 View 提供数据的机制是不同的,简单来说:



  • Presenter 通过持有 View 的引用并直接调用操作 View,以此向 View 提供数据

  • ViewModel 通过将可观察的数据暴露给观察者来向 View 提供数据


官方提供的可观察的数据 组件是 LiveData。Kotlin 1.4.0 正式版发布之后,开发者有了新的选择:StateFlowSharedFlow


最近网上流传出「LiveData 被弃用,应该使用 Flow 替代 LiveData」的声音。


LiveData 真的有那么不堪吗?Flow 真的适合你使用吗?


不人云亦云,只求接近真相。我们今天来讨论一下这两种组件。


ViewModel + LiveData


为了实现高效地加载 UI 数据,获得最佳的用户体验,应实现以下目标:



  • 目标1:已经加载的数据无需在「配置更改」的场景下再次加载

  • 目标2:避免在非活跃状态(不是 STARTEDRESUMED)下加载数据和刷新 UI

  • 目标3:「配置更改」时不会中断的工作


Google 官方在 2017 年发布了架构组件库:使用 ViewModel + LiveData 帮助开发者实现上述目标。



相信很多人在官方文档中见过这个图,ViewModelActivity/Fragment 的生命周期更长,不受「配置更改」导致 Activity/Fragment 重建的影响。刚好满足了目标 1 和目标 3。


LiveData 是可生命周期感知的。 新值仅在生命周期处于 STARTEDRESUMED 状态时才会分配给观察者,并且观察者会自动取消注册,避免了内存泄漏。 LiveData 对实现目标 1 和 目标 2 很有用:它缓存其持有的数据的最新值,并将该值自动分派给新的观察者。


LiveData 的特性


既然有声音说「LiveData 要被弃用了」,那么我们先对 LiveData 进行一个全面的了解。聊聊它能做什么,不能做什么,以及使用过程中有哪些要注意的地方。


LiveData 是 Android Jetpack Lifecycle 组件中的内容。属于官方库的一部分,Kotlin/Java 均可使用。


一句话概括 LiveDataLiveData 是可感知生命周期的,可观察的,数据持有者


它的能力和作用很简单:更新 UI


它有一些可以被认为是优点的特性:



  • 观察者的回调永远发生在主线程

  • 仅持有单个且最新的数据

  • 自动取消订阅

  • 提供「可读可写」和「仅可读」两个版本收缩权限

  • 配合 DataBinding 实现「双向绑定」


观察者的回调永远发生在主线程


这个很好理解,LiveData 被用来更新 UI,因此 ObserveronChanged() 方法在主线程回调。



背后的原理也很简单,LiveDatasetValue() 发生在主线程(非主线程调用会抛异常,postValue() 内部会切换到主线程调用 setValue())。之后遍历所有观察者的 onChanged() 方法。


仅持有单个且最新的数据


作为数据持有者(data holder),LiveData 仅持有 单个最新 的数据。


单个且最新,意味着 LiveData 每次持有一个数据,并且新数据会覆盖上一个。


这个设计很好理解,数据决定了 UI 的展示,绘制 UI 时肯定要使用最新的数据,「过时的数据」应该被忽略。



配合 Lifecycle,观察者只会在活跃状态下(STARTEDRESUMED)接收到 LiveData 持有的最新的数据。在非活跃状态下绘制 UI 没有意义,是一种资源的浪费。



自动取消订阅


这是 LiveData 可感知生命周期的重要表现,自动取消订阅意味着开发者无需手动写那些取消订阅的模板代码,降低了内存泄漏的可能性。


背后原理是在生命周期处于 DESTROYED 时,移除观察者。



提供「可读可写」和「仅可读」两个版本




点击查看代码
public abstract class LiveData<T> {
@MainThread
protected void setValue(T value) {
// ...
}

protected void postValue(T value) {
// ...
}

@Nullable
public T getValue() {
// ...
}
}

public class MutableLiveData<T> extends LiveData<T> {
@Override
public void postValue(T value) {
super.postValue(value);
}
@Override
public void setValue(T value) {
super.setValue(value);
}
}
复制代码



抽象类 LiveDatasetValue()postValue() 是 protected,而其实现类 MutableLiveData 均为 public。



LiveData 提供了 mutable(MutableLiveData) 和 immutable(LiveData) 两个类,前者「可读可写」,后者「仅可读」。通过权限的细化,让使用者各取所需,避免由于权限泛滥导致的数据异常。




点击查看代码
class SharedViewModel : ViewModel() {
private val _user : MutableLiveData<User> = MutableLiveData()

val user : LiveData<User> = _user

fun setUser(user: User) {
_user.posetValue(user)
}
}
复制代码


配合 DataBinding 实现「双向绑定」


LiveData 配合 DataBinding 可以实现 更新数据自动驱动 UI 变化,如果使用「双向绑定」还能实现 UI 变化影响数据的变化。




以下也是 LiveData 的特性,但我不会将其归类为「设计缺陷」或「LiveData 的缺点」。作为开发者应了解这些特性并在使用过程中正确处理它们。



  • value 是 nullable 的

  • 在 fragment 订阅时需要传入正确的 lifecycleOwner

  • LiveData 持有的数据是「事件」时,可能会遇到「粘性事件

  • LiveData 是不防抖的

  • LiveDatatransformation 工作在主线程


value 是 nullable 的




点击查看代码
@Nullable
public T getValue() {
Object data = mData;
if (data != NOT_SET) {
return (T) data;
}
return null;
}
复制代码


LiveData#getValue() 是可空的,使用时应该注意判空。


使用正确的 lifecycleOwner


fragment 调用 LiveData#observe() 方法时传入 thisviewLifecycleOwner 是不一样的。


原因之前写过,此处不再赘述。感兴趣的小伙伴可以移步查看


AS 在 lint 检查时会避免开发者犯此类错误。



粘性事件


官方在 [译] 在 SnackBar,Navigation 和其他事件中使用 LiveData(SingleLiveEvent 案例) 一文中描述了一种「数据只会消费一次」的场景。如展示 Snackbar,页面跳转事件或弹出 Dialog。


由于 LiveData 会在观察者活跃时将最新的数据通知给观察者,则会产生「粘性事件」的情况。


如点击 button 弹出一个 Snackbar,在屏幕旋转时,lifecycleOwner 重建,新的观察者会再次调用 Livedata#observe(),因此 Snackbar 会再次弹出。


解决办法是:将事件作为状态的一部分,在事件被消费后,不再通知观察者。这里推荐两种解决方案:



默认不防抖


setValue()/postValue() 传入相同的值多次调用,观察者的 onChanged() 会被多次调用。


严格讲这不算一个问题,看具体的业务场景,处理也很容易,调用 setValue()/postValue() 前判断一下 vlaue 与之前是否相同即可。




点击查看代码
class MainViewModel {
private val _username = MutableLiveData<String>()
val username: LiveData<String> = _username

fun setUsername(username: String) {
if (_username.value != username)
_headerText.postValue(username)
}
}
复制代码


transformation 工作在主线程


有些时候我们从 repository 层拿到的数据需要进行处理,例如从数据库获得 User List,我们想根据 id 获取某个 User。


此时我们可以借助 MediatorLiveDataTransformatoins 来实现:





点击查看代码
class MainViewModel {
val viewModelResult = Transformations.map(repository.getDataForUser()) { data ->
convertDataToMainUIModel(data)
}
}
复制代码


mapswitchMap 内部均是使用 MediatorLiveData#addSource() 方法实现的,而该方法会在主线程调用,使用不当会有性能问题。




点击查看代码
@MainThread
public <S> void addSource(@NonNull LiveData<S> source, @NonNull Observer<? super S> onChanged) {
Source<S> e = new Source<>(source, onChanged);
Source<?> existing = mSources.putIfAbsent(source, e);
if (existing != null && existing.mObserver != onChanged) {
throw new IllegalArgumentException(
"This source was already added with the different observer");
}
if (existing != null) {
return;
}
if (hasActiveObservers()) {
e.plug();
}
}
复制代码


我们可以借助 Kotlin 协程和 RxJava 实现异步任务,最后在主线程上返回 LiveData。如 androidx.lifecycle:lifecycle-livedata-ktx 提供了这样的写法




点击查看代码
val result: LiveData<Result> = liveData {
val data = someSuspendingFunction() // 协程中处理
emit(data)
}
复制代码


LiveData 小结




  • LiveData 作为一个 可感知生命周期的,可观察的,数据持有者,被设计用来更新 UI




  • LiveData 很轻,功能十分克制,克制到需要配合 ViewModel 使用才能显示其价值




  • 由于 LiveData 专注单一功能,因此它的一些方法使用上是有局限性的,即通过设计来强制开发者按正确的方式编码(如观察者仅在主线程回调,避免了开发者在子线程更新 UI 的错误操作)




  • 由于 LiveData 专注单一功能,如果想在表现层之外使用它,MediatorLiveData 的操作数据的能力有限,仅有的 mapswitchMap 发生在主线程。可以在 switchMap 中使用协程或 RxJava 处理异步任务,最后在主线程返回 LiveData。如果项目中使用了 RxJavaAutoDispose,甚至可以不使用 LiveData,关于 Kotlin 协程的 Flow,我们后文介绍。




  • 笔者不喜欢将 LiveData 改造成 bus 使用,让组件做其分内的事(此条属于个人观点)




Flow


Flow 是 Kotlin 语言提供的功能,属于 Kotlin 协程的一部分,仅 Kotlin 使用。


Kotlin 协程被用来处理异步任务,而 Flow 则是处理异步数据流。


那么 suspend 方法和 Flow 的区别是什么?各自的使用场景是哪些?


一次性调用(One-shot Call)与数据流(data stream)



假如我们的 app 的某一屏里显示以下元素,其中红框部分实时性不高,不必很频繁的刷新,转发和点赞属于实时性很高的数据,需要定时刷新。



对于实时性不高的数据,我们可以使用 Kotlin 协程处理(此处数据的请求是异步任务):


suspend fun loadData(): Data

uiScope.launch {
val data = loadData()
updateUI(data)
}

而对于实时性较高的数据,挂起函数就无能为力了。有的小伙伴可能会说:「返回个 List 不就行了嘛」。其实无论返回什么类型,这种操作都是 One-shot Call,一次性的请求,有了结果就结束。


示例中的点赞和转发,需要一个 数据是异步计算的,能够 按顺序 提供 多个值 的结构,在 Kotlin 协程中我们有 Flow。


fun dataStream(): Flow<Data>

uiScope.launch {
dataStream().collect { data ->
updateUI(data)
}
}


当点赞或转发数发生变化时,updateUI() 会被执行,UI 根据最新的数据更新



Flow 的三驾马车


FLow 中有三个重要的概念:



  • 生产者(Producer)

  • 消费者(Consumer)

  • 中介(Intermediaries)


生产者提供数据流中的数据,得益于 Kotlin 协程,Flow 可以 异步地生产数据


消费者消费数据流内的数据,上面的示例中,updateUI() 方法是消费者。


中介可以对数据流中的数据进行更改,甚至可以更改数据流本身,我们可以借助官方视频中的动画来理解:



在 Android 中,数据层的 DataSource/Repository 是 UI 数据的生产者;而 view/ViewModel 是消费者;换一个角度,在表现层中,view 是用户输入事件的生产者(例如按钮的点击),其它层是消费者。


「冷流」与「热流」


你可能见过这样的描述:「流是冷的」



简单来说,冷流指数据流只有在有消费者消费时才会生产数据。


val dataFlow = flow {
// 代码块只有在有消费者 collect 后才会被调用
val data = dataSource.fetchData()
emit(data)
}

...

dataFlow.collect { ... }

有一种特殊的 Flow,如 StateFlow/SharedFlow ,它们是热流。这些流可以在没有活跃消费者的情况下存活,换句话说,数据在流之外生成然后传递到流。



BroadcastChannel 未来会在 Kotlin 1.6.0 中弃用,在 Kotlin 1.7.0 中删除。它的替代者是 StateFlowSharedFlow



StateFlow


StateFlow 也提供「可读可写」和「仅可读」两个版本。


SateFlow 实现了 SharedFlowMutableStateFlow 实现 MutableSharedFlow



StateFlowLiveData 十分像,或者说它们的定位类似。


StateFlowLiveData 有一些相同点:




  • 提供「可读可写」和「仅可读」两个版本(StateFlowMutableStateFlow




  • 它的值是唯一的




  • 它允许被多个观察者共用 (因此是共享的数据流)




  • 它永远只会把最新的值重现给订阅者,这与活跃观察者的数量是无关的




  • 支持 DataBinding




它们也有些不同点:



  • 必须配置初始值

  • value 空安全

  • 防抖


MutableStateFlow 构造方法强制赋值一个非空的数据,而且 value 也是非空的。这意味着 StateFlow 永远有值




StateFlow 的 emit()tryEmit() 方法内部实现是一样的,都是调用 setValue()



StateFlow 默认是防抖的,在更新数据时,会判断当前值与新值是否相同,如果相同则不更新数据。



SharedFlow


SateFlow 一样,SharedFlow 也有两个版本:SharedFlowMutableSharedFlow



那么它们有什么不同?



  • MutableSharedFlow 没有起始值

  • SharedFlow 可以保留历史数据

  • MutableSharedFlow 发射值需要调用 emit()/tryEmit() 方法,没有 setValue() 方法



MutableSharedFlow 不同,MutableSharedFlow 构造器中是不能传入默认值的,这意味着 MutableSharedFlow 没有默认值。


val mySharedFlow = MutableSharedFlow<Int>()
val myStateFlow = MutableStateFlow<Int>(0)
...
mySharedFlow.emit(1)
myStateFlow.emit(1)

SateFlowSharedFlow 还有一个区别是 SateFlow 只保留最新值,即新的订阅者只会获得最新的和之后的数据。


SharedFlow 根据配置可以保留历史数据,新的订阅者可以获取之前发射过的一系列数据。



后文会介绍背后的原理



它们被用来应对不同的场景:UI 数据是状态还是事件


状态(State)与事件(Event)


状态可以是的 UI 组件的可见性,它始终具有一个值(显示/隐藏)


而事件只有在满足一个或多个前提条件时才会触发,不需要也不应该有默认值


为了更好地理解 SateFlowSharedFlow 的使用场景,我们来看下面的示例:



  1. 用户点击登录按钮

  2. 调用服务端验证登录合法性

  3. 登录成功后跳转首页


我们先将步骤 3 视为 状态 来处理:



使用状态管理还有与 LiveData 一样的「粘性事件」问题,如果在 ViewNavigationState 中我们的操作是弹出 snackbar,而且已经弹出一次。在旋转屏幕后,snackbar 会再次弹出。



如果我们将步骤 3 作为 事件 处理:



使用 SharedFlow 不会有「粘性事件」的问题,MutableSharedFlow 构造函数里有一个 replay 的参数,它代表着可以对新订阅者重新发送多个之前已发出的值,默认值为 0。



SharedFlow 在其 replayCache 中保留特定数量的最新值。每个新订阅者首先从 replayCache 中取值,然后获取新发射的值。replayCache 的最大容量是在创建 SharedFlow 时通过 replay 参数指定的。replayCache 可以使用 MutableSharedFlow.resetReplayCache 方法重置。


replay 为 0 时,replayCache size 为 0,新的订阅者获取不到之前的数据,因此不存在「粘性事件」的问题。


StateFlowreplayCache 始终有当前最新的数据:



至此, StateFlowSharedFlow 的使用场景就很清晰了:


状态(State)用 StateFlow ;事件(Event)用 SharedFlow  


StateFlow,SharedFlow 与 LiveData 的使用对比


LiveData StateFlow SharedFlow 在 ViewModel 中的使用



上图分别展示了 LiveDataStateFlowSharedFlowViewModel 中的使用。


其中 LiveDataViewModel 中使用 LiveEventLiveData 处理「粘性事件


FlowViewModel 中使用 SharedFlow 处理「粘性事件


emit() 方法是挂起函数,也可以使用 tryEmit()



LiveData StateFlow SharedFlow 在 Fragment 中的使用



注意:Flow 的 collect 方法不能写在同一个 lifecycleScope


flowWithLifecyclelifecycle-runtime-ktx:2.4.0-alpha01 后提供的扩展方法



Flow 在 fragment 中的使用要比 LiveData 繁琐很多,我们可以封装一个扩展方法来简化:



关于 repeatOnLifecycle 的设计问题,可以移步 设计 repeatOnLifecycle API 背后的故事


使用 collect 方法时要注意一个问题。



这种写法是错误的!


viewModel.headerText.collect 在协程被取消前会一直挂起,这样后面的代码便不会执行。


Flow 与 RxJava


FlowRxJava 的定位很接近,限于篇幅原因,此处不展开讲,本节只罗列一下它们的对应关系:




  • Flow = (cold) Flowable / Observable / Single




  • Channel = Subjects




  • StateFlow = BehaviorSubjects (永远有值)




  • SharedFlow = PublishSubjects (无初始值)




  • suspend function = Single / Maybe / Completable




参考文档与推荐资源



总结




  • LiveData 的主要职责是更新 UI,要充分了解其特性,合理使用




  • Flow 可分为生产者,消费者,中介三个角色




  • 冷流和热流最大的区别是前者依赖消费者 collect 存在,而热流一直存在,直到被取消




  • StateFlowLiveData 定位相似,前者必须配置初始值,value 空安全并且默认防抖




  • StateFlowSharedFlow 的使用场景不同,前者适用于「状态」,后者适用于「事件」




回到文章开头的话题,LiveData 并没有那么不堪,由于其作用单一,功能简单,简单便意味着不易出错。所以在表现层中ViewModel 向 view 暴露 LiveData 是一个不错的选择。而在 RepositoryDataSource 中,我们可以利用 LiveData + 协程来处理数据的转换。当然,我们也可以使用功能更强大的 Flow


LiveDataStateFLowSharedFlow,它们都有着各自的使用场景。并且如果使用不当,都会或多或少地遇到一些所谓的「坑」。因此在使用某个组件时,要充分了解其设计缘由以及相关特性,否则就会掉进陷阱,收到不符合预期的行为。


作者:Flywith24
链接:https://juejin.cn/post/7007602776502960165
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

android 事件分发机制

Android 事件分发机制解析1. view的事件分发机制view的事件分发是从 dispatchTouchEvent() 开始的,直接上代码;public boolean dispatchTouchEvent(MotionEvent event) { ...
继续阅读 »

Android 事件分发机制解析

1. view的事件分发机制

view的事件分发是从 dispatchTouchEvent() 开始的,直接上代码;

public boolean dispatchTouchEvent(MotionEvent event) {  
boolean result = false;
// 1. view 是否可以点击 && setOnTouchListener 有值 并且 setOnTouchListener 返回值是true 事件分发 结束

if ( (mViewFlags & ENABLED_MASK) == ENABLED &&
mOnTouchListener != null &&
mOnTouchListener.onTouch(this, event)) {
boolean result = true;

}
// 2.如果上述条件不都成立 执行 OnTouchEvent();
if (!result && onTouchEvent(event)) {
result = true;
}


return result;
}

/**
* 分析1:onTouchEvent()
*/
public boolean onTouchEvent(MotionEvent event) {



// 若该控件可点击,则进入switch判断中
if (((viewFlags & CLICKABLE) == CLICKABLE || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)) {

// 根据当前事件类型进行判断处理
switch (event.getAction()) {

// a. 事件类型=抬起View(主要分析)
case MotionEvent.ACTION_UP:
performClick();
// ->>分析2
break;

// b. 事件类型=按下View
case MotionEvent.ACTION_DOWN:
postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
break;

// c. 事件类型=结束事件
case MotionEvent.ACTION_CANCEL:
refreshDrawableState();
removeTapCallback();
break;

// d. 事件类型=滑动View
case MotionEvent.ACTION_MOVE:
final int x = (int) event.getX();
final int y = (int) event.getY();

int slop = mTouchSlop;
if ((x < 0 - slop) || (x >= getWidth() + slop) ||
(y < 0 - slop) || (y >= getHeight() + slop)) {
removeTapCallback();
if ((mPrivateFlags & PRESSED) != 0) {
removeLongPressCallback();
mPrivateFlags &= ~PRESSED;
refreshDrawableState();
}
}
break;
}

// 若该控件可点击,就一定返回true
return true;
}
// 若该控件不可点击,就一定返回false
return false;
}

**
* 分析2:performClick()
*/
public boolean performClick() {

if (mOnClickListener != null) {
// 只要通过setOnClickListener()为控件View注册1个点击事件
// 那么就会给mOnClickListener变量赋值(即不为空)
// 则会往下回调onClick() & performClick()返回true
playSoundEffect(SoundEffectConstants.CLICK);
mOnClickListener.onClick(this);
return true;
}
return false;
}

总结:如果你在执行ACTION_DOWN的时候返回了false,后面一系列其它的action就不会再得到执行了。简单的说,就是当dispatchTouchEvent在进行事件分发的时候,只有前一个action返回true,才会触发后一个action。

image.png

2.ViewGroup 事件分发机制

viewGroup事件分发可以分为以下阶段:1.点击事件是down,将mFirstTarget、其他的标记、状态值清空,2.检查当前事件是否被拦截,3.如果被拦截,当前的事件不会分发给子view(firstTarget为空),会交由viewGroup父类的dispatchTouchEvent处理;4.如果不拦截,会找到一个满足条件的子view,分发此次的down事件;5.如果找不到满足条件的子view,firstTouch=null,就会调用自身的dispatchTouchEvent;6.如何当前点击事件是move、up时;6.如果找到了符合条件的子view,把down事件分发给子view,并对firstTouchTarget赋值,down事件分发结束;7.接来下就是move、up事件的分发,如果down事件分发给子view了,会再次判断是否拦截;8.如果不拦截,就会把move、up分发给mFirstTouchTarget对应的子view;9.如果拦截,会分发一个cancel事件给firstTouchTarget对应的子view。

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {

// 1.将firstTouchTarget置空,其他状态清空
if (actionMasked == MotionEvent.ACTION_DOWN) {
cancelAndClearTouchTargets(ev);
resetTouchState();
}

final boolean intercepted;


/***
* 2.检查是否拦截事件,如果点击事件是down、或者事件已经分发给子view,通过viewGroup的
* onInterceptTouchEvent 判断
*
*/

if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
intercepted = true;
}


TouchTarget newTouchTarget = null;
boolean alreadyDispatchedToNewTouchTarget = false;
// 3. 如果事件没有被拦截,会需找一个满足条件的子view分发事件
if (!canceled && !intercepted) {
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {


if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// Child wants to receive touch within its bounds.
mLastTouchDownTime = ev.getDownTime();
if (preorderedList != null) {
// childIndex points into presorted list, find original index
for (int j = 0; j < childrenCount; j++) {
if (children[childIndex] == mChildren[j]) {
mLastTouchDownIndex = j;
break;
}
}
} else {
mLastTouchDownIndex = childIndex;
}
mLastTouchDownX = ev.getX();
mLastTouchDownY = ev.getY();
/***
*4. 如果有子view可以分发当前的事件,对newTouchTarget,firstTouchTarget赋值,记
* 消费本次事件的view
*/
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}
}
}

if (mFirstTouchTarget == null) {
// 5. 事件交由viewGroup父类的dispatchTouchEvent 处理
// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
// Dispatch to touch targets, excluding the new touch target if we already
// dispatched to it. Cancel touch targets if necessary.
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
// 6.找到符合条件的子view,该事件分发结束
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
//
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}

if (cancelChild) { // 如果拦截了事件,清空 firstTouchTarget
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}

}
private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
View child, int desiredPointerIdBits) {
final boolean handled;


final int oldAction = event.getAction();
// 如果是子view消费了viewGroup分发的事件,后续事件被viewGroup拦截,viewGroup会发送一
cancel事件给firstTouchTarget对应的子view,该事件结束。下一个事件就不会再分发给子view了。

if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
event.setAction(MotionEvent.ACTION_CANCEL);
if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
handled = child.dispatchTouchEvent(event);
}
event.setAction(oldAction);
return handled;
}


final int oldPointerIdBits = event.getPointerIdBits();
final int newPointerIdBits = oldPointerIdBits & desiredPointerIdBits;


if (newPointerIdBits == 0) {
return false;
}

if (child == null) {
handled = super.dispatchTouchEvent(transformedEvent);
} else {
final float offsetX = mScrollX - child.mLeft;
final float offsetY = mScrollY - child.mTop;
transformedEvent.offsetLocation(offsetX, offsetY);
if (! child.hasIdentityMatrix()) {
transformedEvent.transform(child.getInverseMatrix());
}

handled = child.dispatchTouchEvent(transformedEvent);
}
transformedEvent.recycle();
return handled;
}

3.滑动冲突

3.1 滑动冲突场景

方向一致:父容器和子view的滑动方向一致,如:scrollView 嵌套一个recyclewView 方向不一致:父容器和子view的滑动方向不一致,如scrollView 嵌套一个 viewPage。

3.2 外部拦截法

子view需要处理事件时,在父容器里面通过onInterceptTouchEvent返回值为false,让事件交由子view处理;当父容器需要处理事件时,让onInterceptTouchEvent返回值未true,让父容器拦截子view的事件,自己处理事件。伪代码如下:

public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted=false;
int x= (int) event.getX();
int y= (int) event.getY();
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
intercepted=false;//必须不能拦截,否则后续的ACTION_MOME和ACTION_UP事件都会拦截。
break;
case MotionEvent.ACTION_MOVE:
if (父容器需要当前点击事件){
intercepted=true;
}else {
intercepted=false;
}
break;
case MotionEvent.ACTION_UP:
intercepted=false;
break;

default:
break;
}
mLastXIntercept=x;
mLastXIntercept=y;
return intercepted;
}

3.3 内部拦截法

if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}

内部拦截法通过子view(requestDisallowInterceptTouchEvent(disallowIntercept=false),disallowIntercept=false)改变viewGroup的disallowIntercept值,来干预viewGroup是否拦截子view。从上面代码,我们可以知道:disallowIntercept只能控制让viewGroup不拦截子view,拦截子view是通过viewGroup的 onInterceptTouchEvent方法值控制的。所以内部拦截法,就是结合viewGroup的 onInterceptTouchEvent方法和view通过viewgroup.requestDisallowInterceptTouchEvent改变 disallowIntercept值共同来完成。

// 重写 viewGroup  onInterceptTouchEvent方法,down返回值不能为false
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = ev.getAction();
if (action == MotionEvent.ACTION_DOWN) {
return false;
} else {
return true;
}
}

//  重写子view的 dispatchTouchEvent事件 
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN: {
getParent().requestDisallowInterceptTouchEvent(true);
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
//如果是左右滑动
if (父容器) {
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP: {
getParent().requestDisallowInterceptTouchEvent(false);
break;
}
}
mLastXIntercept = x;
mLastYIntercept = y;
return super.dispatchTouchEvent(ev);
}


收起阅读 »

自定义可点击可滑动的通用RatingBar

介绍一个可以设置间距,设置选中未选中图标及数量,选中图标的类型(整,半,任意),可点击,可滑动选择的类似原生RatingBar的自定义View。效果图预览实现自定义属性<declare-styleable name="CommonRatingBar"&g...
继续阅读 »

介绍

一个可以设置间距,设置选中未选中图标及数量,选中图标的类型(整,半,任意),可点击,可滑动选择的类似原生RatingBar的自定义View。

效果图预览

untitled.gif

实现

自定义属性

<declare-styleable name="CommonRatingBar">
<attr name="starCount" format="integer" />
<attr name="starPadding" format="dimension" />
<!-- 默认选中时的图标,可不设置,使用纯色starColor -->
<attr name="starDrawable" format="reference" />
<!-- 默认未选中时的图标 -->
<attr name="starBgDrawable" format="reference" />
<!-- 纯色样式 -->
<attr name="starColor" format="color" />
<attr name="starClickable" format="boolean" />
<attr name="starScrollable" format="boolean" />
<attr name="starType" format="enum">
<enum name="normal" value="0" />
<enum name="half" value="1" />
<enum name="whole" value="2" />
</attr>
</declare-styleable>

测量View

将控件的高度设置为测量高度,测量宽度为星星的数量+每个星星之间的padding

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
starSize = measuredHeight
setMeasuredDimension(starCount * starSize + (starCount - 1) * starPadding.toInt(), starSize)
}

绘制ratingbar

  1. 绘制未选中的背景
/**
* 未选中Bitmap
*/

private val starBgBitmap: Bitmap by lazy {
val bitmap = Bitmap.createBitmap(starSize, starSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val starDrawable = ContextCompat.getDrawable(context, starBgDrawable)
starDrawable?.setBounds(0, 0, starSize, starSize)
starDrawable?.draw(canvas)
bitmap
}

/**
* 绘制星星默认未选中背景
*/

private fun drawStar(canvas: Canvas) {
for (i in 0 until starCount) {
val starLeft = i * (starSize + starPadding)
canvas.drawBitmap(starBgBitmap, starLeft, 0f, starBgPaint)
}
}
  1. 绘制选中图标

这里bitmap宽度使用starSize + starPadding,配合BitmapShader的repeat模式,可以方便绘制出高亮的图标

/**
* 选中icon的Bitmap
*/

private val starBitmap: Bitmap by lazy {
val bitmap = Bitmap.createBitmap(starSize + starPadding.toInt(), starSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val starDrawable = ContextCompat.getDrawable(context, starDrawable)
starDrawable?.setBounds(0, 0, starSize, starSize)
starDrawable?.draw(canvas)
bitmap
}

/**
* 绘制高亮图标
*/

private fun drawStarDrawable(canvas: Canvas) {
starDrawablePaint.shader = BitmapShader(starBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
canvas.drawRect(0f, 0f, getStarProgressWidth(), height.toFloat(), starDrawablePaint)
}
  1. 绘制纯色的选中效果

使用离屏缓冲,纯色矩形与未选中背景相交的地方进行显示。具体使用可以参考扔物线大佬的文章

/**
* 星星纯色画笔
*/

private val starPaint = Paint().apply {
isAntiAlias = true
xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
}

canvas?.let {
// xfermode需要使用离屏缓存
val saved = it.saveLayer(null, null)
drawStar(it)
if (starDrawable == -1) {
drawStarBgColor(it)
} else {
drawStarDrawable(it)
}
it.restoreToCount(saved)
}

/**
* 绘制高亮纯颜色
*/

private fun drawStarBgColor(canvas: Canvas) {
canvas.drawRect(0f, 0f, getStarProgressWidth(), height.toFloat(), starPaint)
}
  1. 绘制进度

根据type更正显示效果,是取半,取整还是任意取进度。open方法,可以方便修改

/**
* 获取星星绘制宽度
*/

private fun getStarProgressWidth(): Float {
val percent = progress / 100f
val starDrawCount = percent * starCount
return when (starType) {
StarType.HALF.ordinal -> {
ceilHalf(starDrawCount) * starSize + starDrawCount.toInt() * starPadding
}
StarType.WHOLE.ordinal -> {
ceilWhole(starDrawCount) * starSize + starDrawCount.toInt() * starPadding
}
else -> {
starDrawCount * starSize + starDrawCount.toInt() * starPadding
}
}
}

/**
* 取整规则
*/

private fun ceilWhole(x: Float): Float {
return ceil(x)
}

/**
* 取半规则
*/

private fun ceilHalf(x: Float): Float {
// 四舍五入 1.3->1+0.5->1.5 1.7->2
val round = round(x)
return when {
round < x -> round + 0.5f
round > x -> round
else -> x
}
}
  1. 点击+滑动

点击+滑动就是重写onTouchEvent事件:

  • 判断点击位置是否在范围内
/**
* 点击的point是否在view范围内
*/

private fun pointInView(x: Float, y: Float): Boolean {
return Rect(0, 0, width, height).contains(x.toInt(), y.toInt())
}
  • 记录按下位置,抬起位置。
MotionEvent.ACTION_DOWN -> {
downX = event.x
downY = event.y
}
MotionEvent.ACTION_UP -> {
if (starClickable && abs(event.y - downY) <= touchSlop && abs(event.x - downX) <= touchSlop && pointInView(event.x, event.y)) {
parent.requestDisallowInterceptTouchEvent(true)
val progress = (event.x / width * 100).toInt()
setProgress(progress)
listener?.onClickProgress(progress)
} else {
parent.requestDisallowInterceptTouchEvent(false)
}
}
  • 滑动记录手指move
MotionEvent.ACTION_MOVE -> {
if (starScrollable && abs(event.x - downX) - abs(event.y - downY) >= touchSlop && pointInView(event.x, event.y)) {
parent.requestDisallowInterceptTouchEvent(true)
val progress = (event.x / width * 100).toInt()
setProgress(progress)
listener?.onScrollProgress(progress)
} else {
parent.requestDisallowInterceptTouchEvent(false)
}
}
  1. 添加监听

添加OnCommonRatingBarListener,监听点击事件以及滑动事件,返回进度

click.gif

完整实现代码

class CommonRatingBar(context: Context, attrs: AttributeSet?) : View(context, attrs) {

/**
* 星星数量
*/

private var starCount = 5

/**
* 星星间隔
*/

private var starPadding = 0f

/**
* 星星大小
*/

private var starSize = 30

/**
* 星星选中背景图
*/

private var starDrawable: Int = -1

/**
* 星星未选中背景图
*/

private var starBgDrawable: Int = -1

/**
* 星星选择类型
*/

private var starType = StarType.NORMAL.ordinal

/**
* 星星颜色
*/

private var starColor: Int = Color.parseColor("#F7B500")

/**
* 星星可点击
*/

private var starClickable = false

/**
* 星星可滑动选择
*/

private var starScrollable = false

/**
* 星星未选中画笔
*/

private val starBgPaint = Paint().apply {
isAntiAlias = true
}

/**
* 星星选中画笔
*/

private val starDrawablePaint = Paint().apply {
isAntiAlias = true
}

/**
* 星星纯色画笔
*/

private val starPaint = Paint().apply {
isAntiAlias = true
xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
}

private var progress = 0

/**
* 选中icon的Bitmap
*/

private val starBitmap: Bitmap by lazy {
val bitmap = Bitmap.createBitmap(starSize + starPadding.toInt(), starSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val starDrawable = ContextCompat.getDrawable(context, starDrawable)
starDrawable?.setBounds(0, 0, starSize, starSize)
starDrawable?.draw(canvas)
bitmap
}

/**
* 未选中Bitmap
*/

private val starBgBitmap: Bitmap by lazy {
val bitmap = Bitmap.createBitmap(starSize, starSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val starDrawable = ContextCompat.getDrawable(context, starBgDrawable)
starDrawable?.setBounds(0, 0, starSize, starSize)
starDrawable?.draw(canvas)
bitmap
}

init {
initView(context, attrs)
starPaint.color = starColor
}

private fun initView(context: Context, attrs: AttributeSet?) {
val obtainStyledAttributes = context.obtainStyledAttributes(attrs, R.styleable.CommonRatingBar)
starCount = obtainStyledAttributes.getInt(R.styleable.CommonRatingBar_starCount, 5)
starPadding = obtainStyledAttributes.getDimension(R.styleable.CommonRatingBar_starPadding, 10f)
starDrawable = obtainStyledAttributes.getResourceId(R.styleable.CommonRatingBar_starDrawable, -1)
starBgDrawable = obtainStyledAttributes.getResourceId(R.styleable.CommonRatingBar_starBgDrawable, -1)
starType = obtainStyledAttributes.getInt(R.styleable.CommonRatingBar_starType, StarType.NORMAL.ordinal)
starColor = obtainStyledAttributes.getColor(R.styleable.CommonRatingBar_starColor, Color.parseColor("#F7B500"))
starClickable = obtainStyledAttributes.getBoolean(R.styleable.CommonRatingBar_starClickable, false)
starScrollable = obtainStyledAttributes.getBoolean(R.styleable.CommonRatingBar_starScrollable, false)
obtainStyledAttributes.recycle()
}

override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
// super.dispatchTouchEvent(event) -> 当前view的onTouchEvent
// false -> viewGroup的onTouchEvent
return if (starClickable || starScrollable) super.dispatchTouchEvent(event)
else false
}

/**
* 最小触摸范围
*/

private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var downX = 0f
private var downY = 0f

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
downX = event.x
downY = event.y
}
MotionEvent.ACTION_MOVE -> {
if (starScrollable && abs(event.x - downX) - abs(event.y - downY) >= touchSlop && pointInView(event.x, event.y)) {
parent.requestDisallowInterceptTouchEvent(true)
val progress = (event.x / width * 100).toInt()
setProgress(progress)
listener?.onScrollProgress(progress)
} else {
parent.requestDisallowInterceptTouchEvent(false)
}
}
MotionEvent.ACTION_UP -> {
if (starClickable && abs(event.y - downY) <= touchSlop && abs(event.x - downX) <= touchSlop && pointInView(event.x, event.y)) {
parent.requestDisallowInterceptTouchEvent(true)
val progress = (event.x / width * 100).toInt()
setProgress(progress)
listener?.onClickProgress(progress)
} else {
parent.requestDisallowInterceptTouchEvent(false)
}
}
}
return true
}

/**
* 点击的point是否在view范围内
*/

private fun pointInView(x: Float, y: Float): Boolean {
return Rect(0, 0, width, height).contains(x.toInt(), y.toInt())
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
starSize = measuredHeight
setMeasuredDimension(starCount * starSize + (starCount - 1) * starPadding.toInt(), starSize)
}

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
if (starBgDrawable == -1) {
return
}
canvas?.let {
// xfermode需要使用离屏缓存
val saved = it.saveLayer(null, null)
drawStar(it)
if (starDrawable == -1) {
drawStarBgColor(it)
} else {
drawStarDrawable(it)
}
it.restoreToCount(saved)
}
}

/**
* 绘制星星默认未选中背景
*/

private fun drawStar(canvas: Canvas) {
for (i in 0 until starCount) {
val starLeft = i * (starSize + starPadding)
canvas.drawBitmap(starBgBitmap, starLeft, 0f, starBgPaint)
}
}

/**
* 绘制高亮纯颜色
*/

private fun drawStarBgColor(canvas: Canvas) {
canvas.drawRect(0f, 0f, getStarProgressWidth(), height.toFloat(), starPaint)
}

/**
* 绘制高亮图标
*/

private fun drawStarDrawable(canvas: Canvas) {
starDrawablePaint.shader = BitmapShader(starBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
canvas.drawRect(0f, 0f, getStarProgressWidth(), height.toFloat(), starDrawablePaint)
}

/**
* 获取星星绘制宽度
*/

private fun getStarProgressWidth(): Float {
val percent = progress / 100f
val starDrawCount = percent * starCount
return when (starType) {
StarType.HALF.ordinal -> {
ceilHalf(starDrawCount) * starSize + starDrawCount.toInt() * starPadding
}
StarType.WHOLE.ordinal -> {
ceilWhole(starDrawCount) * starSize + starDrawCount.toInt() * starPadding
}
else -> {
starDrawCount * starSize + starDrawCount.toInt() * starPadding
}
}
}

private fun ceilWhole(x: Float): Float {
return ceil(x)
}

private fun ceilHalf(x: Float): Float {
// 四舍五入 1.3->1+0.5->1.5 1.7->2
val round = round(x)
return when {
round < x -> round + 0.5f
round > x -> round
else -> x
}
}

/**
* 星星的绘制进度
*/

fun setProgress(progress: Int) {
var p = progress
if (p < 0) p = 0
if (p > 100) p = 100
this.progress = p
postInvalidate()
}

fun setProgress(currentValue: Float, totalValue: Float) {
setProgress((currentValue * 100 / totalValue).toInt())
}

fun setOnCommonRatingBarListener(listener: OnCommonRatingBarListener) {
this.listener = listener
}

private var listener: OnCommonRatingBarListener? = null

interface OnCommonRatingBarListener {
fun onClickProgress(progress: Int)
fun onScrollProgress(progress: Int)
}

enum class StarType {
NORMAL, HALF, WHOLE
}

}

image.png

拓展

  • 修改纯色方法配合LinearGradient,可以有渐变的选中效果
收起阅读 »

Android 控制 ContentProvider的创建

序言随着app隐私政策的收紧,现在不经过用户同意,就收集敏感信息的行为一旦被检测出来。很容易造成app下架。但是有些SDK的初始化是通过注册ContentProvider实现自动调用其onCreate()方法,来实现无感初始化的。如果SDK在ContentPr...
继续阅读 »

序言

随着app隐私政策的收紧,现在不经过用户同意,就收集敏感信息的行为一旦被检测出来。很容易造成app下架。但是有些SDK的初始化是通过注册ContentProvider实现自动调用其onCreate()方法,来实现无感初始化的。如果SDK在ContentProvider中获取了敏感信息,又没有提供控制方法。我们就很被动。于是我花了点时间研究了怎么hook contentProvider的创建。让其在用户同意后再初始化。

方案1

声明在清单文件中的ContentProvider 会在应用启动后就创建。具体是在 ActivityThread的handleBindApplication方法中。(以下截图为Android 30的ActivityThread) 在这里插入图片描述 具体就在这一句 在这里插入图片描述

installContentProviders实现如下 在这里插入图片描述 最终是通过AppComponentFactory的instantiateProvider方法创建。 在这里插入图片描述 在这里插入图片描述

而AppComponentFactory是Android 28以后系统提供给我们的一个hook的工厂类。可以通过清单文件指定,在这里面可以hook 所有组件的初始化。 在这里插入图片描述 这么指定 在这里插入图片描述

但是在Android 28以下,比如这个截图是Android 25.没有这类,ContentProvider直接通过反射获得。无法通过该类来修改。 在这里插入图片描述

最终方案

为了兼容性,考虑如下方案。在调用installContentProviders前,如果这个data里面的providers为空岂不是不会走installContentProviders方法了吗。 在这里插入图片描述 这个data 是一个AppBindData类型,通过handleBindApplication方法的参数传入。会保存到ActivityThread的 mBoundApplication 字段中。 在这里插入图片描述

于是就可以通过获取这个mBoundApplication 字段中的providers 来保存要初始化的provider。再讲providers置为空即可。到了用户同意以后,再去通过反射调用ActivityThread的installContentProviders方法即可。 在这里插入图片描述

hook时机

这个时机只有Application的attachBaseContext方法中。该方法会比installContentProviders提前执行。

最后的代码App中

public class MyApp extends Application {

static MyApp app;

/**
*用户同意
*/

public static void agree(Action action) {
HookUtil.initProvider(app);
action.doAction();
}

public interface Action {
void doAction();
}


@Override
protected void attachBaseContext(Context base) {
app = this;
try {
HookUtil.attachContext();
} catch (Exception e) {
e.printStackTrace();
}
super.attachBaseContext(base);
}
}

HookUtil

package com.zgh.testcontentprovider;

import android.content.Context;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.List;


/**
* Created by zhuguohui
* Date: 2021/9/13
* Time: 11:23
* Desc:
*/

public class HookUtil {

private static Object providers;

private static Method installContentProvidersMethod;
private static Object currentActivityThread;

/*
*用户同意后调用
*/

public static void initProvider(Context context){
try {
installContentProvidersMethod.invoke(currentActivityThread,context,providers);
} catch (Exception e) {
e.printStackTrace();
}
}

public static void attachContext() throws Exception {

// 先获取到当前的ActivityThread对象
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
currentActivityThreadMethod.setAccessible(true);
//currentActivityThread是一个static函数所以可以直接invoke,不需要带实例参数
currentActivityThread = currentActivityThreadMethod.invoke(null);

hookInstallContentProvider(activityThreadClass);

}

private static void hookInstallContentProvider(Class activityThreadClass) throws Exception{
Field appDataField = activityThreadClass.getDeclaredField("mBoundApplication");
appDataField.setAccessible(true);
Object appData= appDataField.get(currentActivityThread);
Field providersField= appData.getClass().getDeclaredField("providers");
providersField.setAccessible(true);
providers = providersField.get(appData);
//清空provider,避免有些sdk通过provider来初始化
providersField.set(appData,null);

installContentProvidersMethod = activityThreadClass.getDeclaredMethod("installContentProviders", Context.class, List.class);
installContentProvidersMethod.setAccessible(true);
}
}


收起阅读 »

Jetpack Compose Banner即拿即用

Jetpack Compose目前没有官方的Banner控件,所以只能自己写,搜了些资料才完成,非常感谢之前分享过这些内容的大佬们。 效果图 accompanist组库 accompanist 旨在为Jetpack Compose提供补充功能的组库,里面有非...
继续阅读 »

Jetpack Compose目前没有官方的Banner控件,所以只能自己写,搜了些资料才完成,非常感谢之前分享过这些内容的大佬们。


效果图


gif图.gif


accompanist组库


accompanist


旨在为Jetpack Compose提供补充功能的组库,里面有非常多很好用的实验性功能,之前用过的加载网络图片的rememberImagePainter就是其中之一,而做Banner的话需要用到的是其中的Pager库。


//导入依赖 
implementation "com.google.accompanist:accompanist-pager:$accompanist_pager"

这里我用的是0.16.1,因为其他库也是这个版本,目前最新是0.18.0


关键代码


1、rememberPagerState

用于记录分页状态的变量,一共有5个参数,我们用到了4个,还有一个是initialPageOffset,可以设置偏移量


val pagerState = rememberPagerState(
//总页数
pageCount = list.size,
//预加载的个数
initialOffscreenLimit = 1,
//是否无限循环
infiniteLoop = true,
//初始页面
initialPage = 0
)

2、HorizontalPager

用于创建一个可以横向滑动的分页布局,把上面的rememberPagerState传进去,其他也没啥


HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize(),
) { page ->
Image(
painter = rememberImagePainter(list[page].imageUrl),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
contentDescription = null
)
}

3、让HorizontalPager自己动起来

这里有两个方法可以让HorizontalPager动起来,一个是animateScrollToPage,另一个是scrollToPage,从名字上都可以看出来带animate的是有动画效果的方法,也正是我想要的东西。


//自动滚动
LaunchedEffect(pagerState.currentPage) {
if (pagerState.pageCount > 0) {
delay(timeMillis)
pagerState.animateScrollToPage((pagerState.currentPage + 1) % pagerState.pageCount)
}
}

在控件里添加这行代码就可以让控件自动起来了


但这是一段看起来没问题的代码


假设页面总数pagerState.pageCount为2,当((pagerState.currentPage + 1) % pagerState.pageCount) == 0时跳转到第1个页面,但最后的效果是这样的


gif图2.gif
轮播图往左滑了,而且还出现了轮播图中间页面的画面,页面有点闪烁的感觉。


修改后

//自动滚动
LaunchedEffect(pagerState.currentPage) {
if (pagerState.pageCount > 0) {
delay(timeMillis)
//这里直接+1就可以循环,前提是pagerState的infiniteLoop == true
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}

只修改了animateScrollToPage参数的值,看到这里可能有人会问:pagerState.currentPage + 1不会报错吗?


确实不会!


因为当rememberPagerState中的infiniteLoop(无限循环)参数设置为true时最大页码其实为Int.MAX_VALUE,而currentPage只是当前页面的索引,并不是真实的页码。


也就是说,当Banner有4个页面,这里传个5的时候,并不会报错,而且animateScrollToPage会自动将这个"5"转换为页面索引,以保证下次使用currentPage不会出错。(菜鸟,我!啊吧啊吧看了好一阵子源码没看到这个是哪里转的)


不过有些地方值得注意:



调用pagerState.animateScrollToPage(target)的时候



  • 当target > pageCount 或 target > currentPage的时候,控件向右滑动

  • 当target < pageCount 且 target < currentPage的时候,控件向左滑动

  • 另外如果currentPage和target当两者相差页面大于4的时候只会在动画中显示(currentPage、currentPage + 1、target - 1、target)四个页面



以此类推,如果改为-1的话就是不断往左自动滑动啦


pagerState.animateScrollToPage(pagerState.currentPage - 1)

Banner中定义了几个参数,indicatorAlignment可以设置指示点的位置,默认为底部居中


/**
* 轮播图
* [timeMillis] 停留时间
* [loadImage] 加载中显示的布局
* [indicatorAlignment] 指示点的的位置,默认是轮播图下方的中间,带一点padding
* [onClick] 轮播图点击事件
*/
@ExperimentalCoilApi
@ExperimentalPagerApi
@Composable
fun Banner(
list: List<BannerData>?,
timeMillis: Long = 3000,
@DrawableRes loadImage: Int = R.mipmap.ic_web,
indicatorAlignment: Alignment = Alignment.BottomCenter,
onClick: (link: String) -> Unit = {}
)

Alignment.BottomStart

bannerLeft.png


Alignment.BottomEnd

bannerRight.png


发现了个奇怪的问题


//自动滚动
LaunchedEffect(pagerState.currentPage) {
if (pagerState.pageCount > 0) {
delay(timeMillis)
//这里直接+1就可以循环,前提是infiniteLoop == true
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
}

这段代码里,由于ReCompose时机是因为pagerState.currentPage这个值产生变化的时候;当我们触摸着HorizontalPager这个控件期间,动画会挂起取消


所以当我们滑动但是不滑动到上一页或下一页,且在本次跳转页面动画触发后才松开手指的时候,就会导致自动滚动停止的问题发生。


像这样


gif图3.gif


问题解决


问题的解决思路也不复杂,只需要在手指按下时记录当前页面索引,手指抬起时判断当前页面索引是否有所改变,如果没有改变的话就手动触发动画。


PointerInput Modifier


这是用于处理手势操作的Modifier,它为我们提供了PointerInputScope作用域,在这个作用域中我们可以使用一些有关于手势的API。


例如:detectDragGestures


我们可以在detectDragGestures中拿到拖动开始/拖动时/拖动取消/拖动结束的回调,但其中的onDrag(拖动时触发回调)是必传的参数,这会导致HorizontalPager控件拖动手势失效。


suspend fun PointerInputScope.detectDragGestures(
onDragStart: (Offset) -> Unit = { },
onDragEnd: () -> Unit = { },
onDragCancel: () -> Unit = { },
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
)

所以最后使用了更基础的API - awaitPointerEvent,我们需要在awaitPointerEventScope方法为我们提供的AwaitPointerEventScope作用域内使用它。


HorizontalPager(
state = pagerState,
modifier = Modifier.pointerInput(pagerState.currentPage) {
awaitPointerEventScope {
while (true) {
//PointerEventPass.Initial - 本控件优先处理手势,处理后再交给子组件
val event = awaitPointerEvent(PointerEventPass.Initial)
//获取到第一根按下的手指
val dragEvent = event.changes.firstOrNull()
when {
//当前移动手势是否已被消费
dragEvent!!.positionChangeConsumed() -> {
return@awaitPointerEventScope
}
//是否已经按下(忽略按下手势已消费标记)
dragEvent.changedToDownIgnoreConsumed() -> {
//记录下当前的页面索引值
currentPageIndex = pagerState.currentPage
}
//是否已经抬起(忽略按下手势已消费标记)
dragEvent.changedToUpIgnoreConsumed() -> {
//当pageCount大于1,且手指抬起时如果页面没有改变,就手动触发动画
if (currentPageIndex == pagerState.currentPage && pagerState.pageCount > 1) {
executeChangePage = !executeChangePage
}
}
}
}
}
}
...
)

另外,由于轮播图可以点击跳转到详情页面,所以还需要区分单击事件和滑动事件,需要用到pagerState.targetPage(当前页面是否有任何滚动/动画正在执行),如果没有的话就会返回null。


但只要用户拖动了Banner,松手的时候targetPage就不会为null。


//是否已经抬起(忽略按下手势已消费标记)
dragEvent.changedToUpIgnoreConsumed() -> {
//当页面没有任何滚动/动画的时候pagerState.targetPage为null,这个时候是单击事件
if (pagerState.targetPage == null) return@awaitPointerEventScope
//当pageCount大于1,且手指抬起时如果页面没有改变,就手动触发动画
if (currentPageIndex == pagerState.currentPage && pagerState.pageCount > 1) {
executeChangePage = !executeChangePage
}
}

gif图4.gif
解决!(gif图切换的时候卡了一下,真机上没问题)


即拿即用


给小林一个star


import androidx.annotation.DrawableRes
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.compose.rememberImagePainter
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.rememberPagerState
import kotlinx.coroutines.delay

/**
* 轮播图
* [timeMillis] 停留时间
* [loadImage] 加载中显示的布局
* [indicatorAlignment] 指示点的的位置,默认是轮播图下方的中间,带一点padding
* [onClick] 轮播图点击事件
*/
@ExperimentalCoilApi
@ExperimentalPagerApi
@Composable
fun Banner(
list: List<BannerData>?,
timeMillis: Long = 3000,
@DrawableRes loadImage: Int = R.mipmap.ic_web,
indicatorAlignment: Alignment = Alignment.BottomCenter,
onClick: (link: String) -> Unit = {}
) {

Box(
modifier = Modifier.background(MaterialTheme.colors.background).fillMaxWidth()
.height(220.dp)
) {

if (list == null) {
//加载中的图片
Image(
painterResource(loadImage),
modifier = Modifier.fillMaxSize(),
contentDescription = null,
contentScale = ContentScale.Crop
)
} else {
val pagerState = rememberPagerState(
//总页数
pageCount = list.size,
//预加载的个数
initialOffscreenLimit = 1,
//是否无限循环
infiniteLoop = true,
//初始页面
initialPage = 0
)

//监听动画执行
var executeChangePage by remember { mutableStateOf(false) }
var currentPageIndex = 0

//自动滚动
LaunchedEffect(pagerState.currentPage, executeChangePage) {
if (pagerState.pageCount > 0) {
delay(timeMillis)
//这里直接+1就可以循环,前提是infiniteLoop == true
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}

HorizontalPager(
state = pagerState,
modifier = Modifier.pointerInput(pagerState.currentPage) {
awaitPointerEventScope {
while (true) {
//PointerEventPass.Initial - 本控件优先处理手势,处理后再交给子组件
val event = awaitPointerEvent(PointerEventPass.Initial)
//获取到第一根按下的手指
val dragEvent = event.changes.firstOrNull()
when {
//当前移动手势是否已被消费
dragEvent!!.positionChangeConsumed() -> {
return@awaitPointerEventScope
}
//是否已经按下(忽略按下手势已消费标记)
dragEvent.changedToDownIgnoreConsumed() -> {
//记录下当前的页面索引值
currentPageIndex = pagerState.currentPage
}
//是否已经抬起(忽略按下手势已消费标记)
dragEvent.changedToUpIgnoreConsumed() -> {
//当页面没有任何滚动/动画的时候pagerState.targetPage为null,这个时候是单击事件
if (pagerState.targetPage == null) return@awaitPointerEventScope
//当pageCount大于1,且手指抬起时如果页面没有改变,就手动触发动画
if (currentPageIndex == pagerState.currentPage && pagerState.pageCount > 1) {
executeChangePage = !executeChangePage
}
}
}
}
}
}
.clickable(onClick = { onClick(list[pagerState.currentPage].linkUrl) })
.fillMaxSize(),
) { page ->
Image(
painter = rememberImagePainter(list[page].imageUrl),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
contentDescription = null
)
}

Box(
modifier = Modifier.align(indicatorAlignment)
.padding(bottom = 6.dp, start = 6.dp, end = 6.dp)
) {

//指示点
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
for (i in list.indices) {
//大小
var size by remember { mutableStateOf(5.dp) }
size = if (pagerState.currentPage == i) 7.dp else 5.dp

//颜色
val color =
if (pagerState.currentPage == i) MaterialTheme.colors.primary else Color.Gray

Box(
modifier = Modifier.clip(CircleShape).background(color)
//当size改变的时候以动画的形式改变
.animateContentSize().size(size)
)
//指示点间的间隔
if (i != list.lastIndex) Spacer(
modifier = Modifier.height(0.dp).width(4.dp)
)
}
}

}
}

}

}

/**
* 轮播图数据
*/
data class BannerData(
val imageUrl: String,
val linkUrl: String
)

特别感谢


RugerMc 手势处理


apk下载链接


项目地址


欢迎Star~PlayAndroid


作者:木木沐目
链接:https://juejin.cn/post/7006230365467574302
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

三步实现一个自定义任意路径的嫦娥奔月(Flutter版)

前言 可能不少人看到这个标题,心里想的是: 要是被我发现你TM就是个标题党,三步完不成,信不信我堵在你家门口,见一次打一次,你给我去死吧 不就是个平移动画嘛,我上我也行,让我进去骂死这个水文货 要是真这么想的话,我只能说: 下面给大家整个活...
继续阅读 »

前言


可能不少人看到这个标题,心里想的是:


要是被我发现你TM就是个标题党,三步完不成,信不信我堵在你家门口,见一次打一次,你给我去死吧


不就是个平移动画嘛,我上我也行,让我进去骂死这个水文货


要是真这么想的话,我只能说:



我看你是完全不懂哦
真拿你没办法

下面给大家整个活,为大家介绍一下我们“listView是万能的”教会的唯一真主和慈父——ListView,是如何通过自定义,来实现这个需求的;


先放上效果图:


最终效果

前期准备,需要自定义并提供给ListView的部分;


1. 首先,我们需要一个又大又圆的月亮:


这里呢,就先用一个背景图替代,所以把一个背景图放到stack底层中:


Stack(
children: [
Positioned.fill(
child: Image.asset("img/bg_mid_autumn.jpg",fit: BoxFit.cover,),
),
Positioned.fill(
/// 自定义的ListView
/// 先以RecyclerView的形式命个名,毕竟思路参考自Android 的RecyclerView
child: RecyclerView.builder(...),
),
],

2. 以及主人公————嫦娥:


把它以item的形式加入到自定义ListView中


RecyclerView.builder(
...
itemBuilder: (context, index) {
return Container(
width: 100,
alignment: AlignmentDirectional.topCenter,
child: Image.asset("img/img_chang_e.png",fit: BoxFit.cover,width: 100,height: 100,),
);
}
)

3. 搞一个提供规划登月路径的Widget:


class ImageEditor extends CustomPainter {
ImageEditor();

Path? drawPath;

final Paint painter = new Paint()
..color = Colors.blue
..style = PaintingStyle.stroke
..strokeWidth = 10;

void update(Offset offset) {
if (drawPath == null) {
drawPath = Path()..moveTo(offset.dx, offset.dy);
}

drawPath?.lineTo(offset.dx, offset.dy);
}

@override
void paint(Canvas canvas, Size size) {
if (drawPath != null) {
canvas.drawPath(drawPath!, painter);
}
}

@override
bool shouldRepaint(CustomPainter oldDelegate) {
return true;
}
}

搞定~正好三步;


大家先别急着骂,先不提这个自定义的ListView以及一堆莫名其妙的东西从哪来的,就说是不是三步吧


虽然要实现这三步,需要做如下工作来实现:


关于奔月动画的实现原理、方式


这块参考自android的RecyclerView的自定义LayoutManager的部分,具体详细步骤可以看这个大佬的文章:


# Android自定义LayoutManager第十一式之飞龙在天


这块仅供提供思路,虽说Flutter中没有RecyclerView这种神器,也没有layoutManager这种东西,甚至onMeasure、onLayout这块的触发时机等方面跟android都不同;


但是下沉到onMeasure、onDraw、onLayout这个层面,其实都是一样的,并非不可参考


分析与实现,需要改造ListView哪些地方:


1. 首先,我们先从 ListView 本身开始:


ListView的结构其实并不复杂,或者嚣张点,大部分可滑动的View,也无非就在那几个类上面修修改改,换句话说:


学姐

当然我知道各位一点都不喜欢看代码(其实是因为这部分太多了……放一篇介绍文章中放不下),那我简化一下,只提一下这次涉及的部分和浅层解析,毕竟这块东西我也是简单了解一下(纯属个人理解,有错误请狠狠的打我脸):



  1. ListView、nestedScrollView、CustomScrollView等滑动View,都是直接或者间接继承自ScrollView,ScrollView这个抽线类,就是黑龙江职业学院,那几个可滑动View都是受ScrollView管控;

  2. ScrollView 中管事的就是Scrollable ,把它当成学生会就行;

  3. 在这次中,Scrollable 中有这么几个类要知道:ViewPortScrollControllScrollPostion


ViewPort负责管理提供可视范围视图(学生会生活部?负责提供我们去哪里查寝)、ScrollPostion负责记录滚动位置、最大最小距离之类的信息(学生会书记?记录一下查寝结果)、ScrollControll负责统筹滚动视图的展示、动画等部分(这个我懂,这个是主席,张美玉学姐好);


2. 打破ListView不可滚动溢出的限制,并控制初始位置:


要是嫌麻烦,直接往listView的item列表的头尾处,加个listView大小的空白页,也是可以实现同样效果的


用于装逼,了解listView逻辑思路的写法:



  1. 按照上面的分析,如果要让listView可以滚动溢出,那么需要做的事,就是去找ViewPort的麻烦;


下面我们来回忆一下,一个控件,想要显示,不可避免要经过的三个步骤是:


1、measure;2、layout;3、draw


要想获取滚动限制、明显是measure或者layout部分的东西,结合ScrollPostion的_minScrollExtent和_maxScrollExtent的来源,可以定位可以修改的位置是在 RenderViewPortperformLayout 方法中,调用 scrollPosition 的 applyContentDimensions 方法的地方;


比如说这样修改,将ListView本身大小作为滚动溢出范围:


do {
assert(offset.pixels != null);
correction = _attemptLayout(mainAxisExtent, crossAxisExtent,
offset.pixels + centerOffsetAdjustment);
if (correction != 0.0) {
offset.correctBy(correction);
} else {
if (offset.applyContentDimensions(

/// 在这里调整可溢出范围,比如说下面就把size.width 作为可溢出范围,最小范围减少Size.width,最大范围增加Size.width;
math.min(-size.width, _minScrollExtent + mainAxisExtent * anchor),
math.max(0.0,
_maxScrollExtent - mainAxisExtent * (1.0 - anchor) + size.width),
)) break;
}
count += 1;
} while (count < _maxLayoutCycles);


  1. 然后让ListView的初始展示位置,设置到-Size.width的位置;


在这里我的做法是通过 LayoutBuilder 获取约束范围,然后将约束最大值直接赋值给 ScrollController,例如下面代码:


 LayoutBuilder(builder: (_context, _constraint) {

return RecyclerView.builder(
scrollDirection: Axis.horizontal,

/// 这里将约束的最大值的负数提供到ScrollController的initialScrollOffset中
controller: ScrollController(
initialScrollOffset: -_constraint.maxWidth),
itemCount: 3,
reverse: true,
addRepaintBoundaries: false,
....
)
}

PS : 这块的源码,虽说我们只需要改这么一个小点,但是像override这种方式都会因为一堆私有变量什么的无法获取,所以直接从 RenderViewportBase 到 RenderViewPort 都完整复制出来吧



  1. 最后将自定义好的ViewPort的Render部分,传给ViewPort的Widget部分,最后放到自定义ListView的buildViewPort部分:(在这里,我将这个提供溢出滚动的ViewPort命名为OverScrollViewPort)


@override
Widget buildViewport(BuildContext context, ViewportOffset offset, AxisDirection axisDirection, List<Widget> slivers) {
if (shrinkWrap) {
return OverScrollShrinkWrappingViewport(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
clipBehavior: clipBehavior,
);
}

return OverScrollViewPort(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
cacheExtent: cacheExtent,
center: center,
anchor: anchor,
clipBehavior: clipBehavior,
);
}

如果我没有遗漏部分的话,这时候运行一下代码,应该是这种效果:


最终效果

3. 修改绘制,按path要求绘制:


如果你做好了准备工作,提供了一个自定义路径出来,那么将这个path传到负责绘制的 RenderObject 中,在paint方法中获取滑动比例对应path的位置,就调整绘制位置:


@override
void paint(PaintingContext context, Offset offset) {
...

/// 在这里处理path。
Path? customPath;
PathMetric? pm;
double? length;
if (layoutManager is PathLayoutManager) {
customPath = layoutManager.path;

var pathMetrics = customPath.computeMetrics(forceClosed: false);
pm = pathMetrics.elementAt(0);
length = pm.length;
}

while (child != null) {
double mainAxisDelta = childMainAxisPosition(child);
final double crossAxisDelta = childCrossAxisPosition(child);
...

/// 关于这块去掉原先 mainAxisDelta < constraints.remainingPaintExtent 部分的原因
/// 是因为之前第一个item会在滚动到边界前就被移除绘制
/// 具体是什么地方修改导致的,忘了(๑>؂<๑)
if (mainAxisDelta + paintExtentOf(child) > 0) {
if (customPath != null) {
var percent = (childOffset.dx + child.size.width) /
(child.size.width + constraints.viewportMainAxisExtent);
var tf = pm!.getTangentForOffset(length! * percent);
print("test :${tf?.position}");

var childItemOffset = childOffset;

if (tf?.position != null) {
/// 这里的50 魔法数,是因为之前设置item的height为100,
/// 因为listView好像强制将item的高度固定为listView的高度(横向情况)
/// 这块找个时间研究下怎么搞
/// 强调下,好孩子不要学我这写法
childItemOffset = Offset(
tf!.position.dx - child.size.width / 2, tf.position.dy - 50);
}

context.pushTransform(
needsCompositing,
childItemOffset,
Matrix4.identity(),
// Pre-transform painting function.
painter,
);
} else {
context.paintChild(child, childOffset);
}
}

...
}

...
}

PS:我这里弄了个LayoutManager,其实就是新建个类,把它从widget传到 renderObject &@&%……#;path的处理这块也是有问题的,不应该放在这里搞,好孩子不要学我这么搞,我这是实验性代码…………


当然,要想做到完美复刻RecyclerView,还有不少地方要改动


比如说,你给item加个点击事件,你会发现……现在这种方式,仅仅是改变了绘制的位置,item本身并未移动:


注意看弹toast前的点击位置,明明是左上角


现存问题

我猜想:这里就要涉及到listView 的 insertAndLayout 部分了,进而涉及到整体的滑动逻辑…………或者是hitTest的部分?(或许这是part 2新篇预告?)


在现在这个基础上,还有可以拓展的方面:


除了嫦娥奔月效果,其实还可以实现一些其他效果,例如:


覆盖翻页效果
覆盖翻页效果


item变换
item变换


另外在ParentData等部分中,也有一些有点意思的东西,个人感觉都挺有用的


题外话,上面正文的做法,为什么我个人并不推荐


在我看来,现在文中的这种自定义方式是不符合flutter的推荐方式的:


在我的理解中,在做flutter的自定义的时候,有个比较重要的一句话是需要遵守的:


万物均为widget


所以,如果可以的话,尽量使用widget来代替回调、方法这种,如果无法避免,也尽量约束到一个widget、及其对应element、renderObject;


所以,现在文中的方式,在我看来,虽然能实现需求,但是是通过各种回调、耦合了各个widget的及其对应的element、renderObject,因此不是flutter的良好代码,


这段代码,应急可以,偷懒也行,用于学习思路,分析步骤也是没问题的,但是,不推荐真这么搞哈


这篇文章的主要目的,是参考Android的实现方式,来分享思路与分析flutter中的listView,以及最重要的:



作者:lwlizhe
链接:https://juejin.cn/post/7007254000307437598
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Compose 实现月亮阴晴圆缺动画

效果图 人有悲欢离合,月有阴晴圆缺,此事古难全。 但愿人长久,千里共婵娟。 恰逢中秋佳节,我们今天就使用Compose来实现一下月相变化动画吧~ 感兴趣的同学可以点个Star : Compose 实现月亮阴晴圆缺动画 主要思路 满天繁星 为了实现月相动画...
继续阅读 »

效果图




人有悲欢离合,月有阴晴圆缺,此事古难全。

但愿人长久,千里共婵娟。

恰逢中秋佳节,我们今天就使用Compose来实现一下月相变化动画吧~

感兴趣的同学可以点个Star : Compose 实现月亮阴晴圆缺动画



主要思路


满天繁星


为了实现月相动画,我们首先需要一个背景,因此我们需要一个好看的星空,最好还有闪烁的效果

为为实现星空背景,我们需要做以下几件事



  1. 绘制背景

  2. 生成几十个星星,在背景上随机分布

  3. 通过scalealpha动画,实现每个星星的闪烁效果


我们一起来看下代码


@Composable
fun Stars(starNum: Int) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val list = remember { mutableStateListOf<Star>() }
LaunchedEffect(true) {
for (i in 0..starNum) {
delay(100L)
//添加星星,它们的位置在屏幕上随机
list.add(Star(maxWidth.value * density, maxHeight.value * density))
}
}
list.forEach {
Star(it)
}
}
}

@Composable
fun Star(star: Star) {
var progress: Float by remember { mutableStateOf(0f) }
val infiniteTransition = rememberInfiniteTransition()
....
star.updateStar(progress) // 通过动画更新progress,从而更新star的属性值
Canvas(modifier = Modifier.wrapContentSize()) {
scale(star.scale, Offset(star.x, star.y)) { // 缩放动画
drawCircle(
star.starColor,
star.radius,
center = Offset(star.x, star.y),
alpha = star.alpha // alpha动画
)
}
}
}

月相变化


月相,天文学术语。(phase of the moon)是天文学中对于地球上看到的月球被太阳照明部分的称呼。随着月亮每天在星空中自东向西移动一大段距离,它的形状也在不断地变化着,这就是月亮位相变化,叫做月相。

它的变化过程如下图所示



每个阶段都有各自的名字,如下图所示:


可以看出,月相变化过程还是有些复杂的,那我们怎么实现这个效果呢?


思路分析


为了实现月相变化,首先我们需要画一个圆,代表月亮,最终的满月其实就是这样,比较简单

有了满月,如何在它的基础上,画出其它的月相呢?我们可以通过图像混合模式来实现


图像混合模式定义的是,当两个图像合成时,图像最终的展示方式。在Androd中,有相应的API接口来支持图像混合模式,即Xfermode.

图像混合模式主要有以下16种,以下这张图片从一定程度上形象地说明了图像混合的作用,两个图形一圆一方通过一定的计算产生不同的组合效果,具体如下


我们为了实现月相动画,主要需要使用以下两种混合模式



  • DST_OUT:只在源图像和目标图像不相交的地方绘制【目标图像】,在相交的地方根据源图像的alpha进行过滤,源图像完全不透明则完全过滤,完全透明则不过滤

  • DST_OVER:将目标图像放在源图像上方


我们已经了解了图形混合模式,那么需要在满月上画什么才能实现其它效果呢?

我们可以通过在满月上放一个半圆+一个椭圆来实现



  1. 如上所示,椭圆上水平的线叫长轴,竖直的线叫短轴

  2. 短轴不变,长轴半径从0到满月半径发生变化,再加上一个半圆,就可以实现不同的月相

  3. 比如为了画上蛾眉月,可以通过左半边画半圆,再加上一个椭圆,两都都使用DST_OVER混合模式来实现,就实现了它们两的并集,然后覆盖在下层满月上,就实现了上蛾眉月

  4. 为了画渐盈凸月,则同样就左半边以DST_OVER画半圆,再以DST_OUT画椭圆,就只剩下半圆与椭圆不相交的部分,再与下层的满月混合,就实现了渐盈凸月


这样说可能还是比较抽象,感兴趣的同学可下载源码详细了解下


源码实现


//月亮动画控件
@Composable
fun Moon(modifier: Modifier) {
var progress: Float by remember { mutableStateOf(0f) }
BoxWithConstraints(modifier = modifier) {
Canvas(
modifier = Modifier
.size(canvasSize)
.align(Alignment.TopCenter)
) {
drawMoonCircle(this, progress)
drawIntoCanvas {
it.withSaveLayer(Rect(0f, 0f, size.width, size.height), paint = Paint()) {
if (progress != 1f) {
//必须先画半圆,再画椭圆
drawMoonArc(this, it, paint, progress)
drawMoonOval(this, it, paint, progress)
}
}
}
}
}
}

// 1.首先画一个满月
private fun drawMoonCircle(scope: DrawScope, progress: Float) {
//....
drawCircle(Color(0xfff9dc60))
}

// 2. 画半圆
private fun drawMoonArc(scope: DrawScope, canvas: Canvas, paint: Paint, progress: Float) {
val sweepAngle = when { //从新月到满月在一边画半圆,从满月回到新月则在另一边画半圆
progress <= 0.5f -> 180f
progress <= 1f -> 180f
progress <= 1.5f -> -180f
else -> -180f
}
paint.blendMode = BlendMode.DstOver //半圆的混合模式始终是DstOver
scope.run {
canvas.drawArc(Rect(0f, 0f, size.width, size.height), 90f, sweepAngle, false, paint)
}
}

// 3. 画椭圆
private fun drawMoonOval(scope: DrawScope, canvas: Canvas, paint: Paint, progress: Float) {
val blendMode = when { //椭圆的混合模式会发生变化,这里需要注意下
progress <= 0.5f -> BlendMode.DstOver
progress <= 1f -> BlendMode.DstOut
progress <= 1.5f -> BlendMode.DstOut
else -> BlendMode.DstOver
}
paint.blendMode = blendMode
scope.run {
canvas.drawOval(
Rect(offset = topLeft, size = Size(horizontalAxis, verticalAxis)), //椭圆的长轴会随着动画变化
paint = paint
)
}
}

如上所示:



  1. 主要就是3个步骤,画满月,再画半圆,再画椭圆

  2. 半圆的混合模式始终是DstOver,而椭圆的混合模式会发生变化,它们的颜色都是黑色。

  3. 可以看到半圆与椭圆新建了一个Layer,混合模式的变化,表示的就是最后剩下的是它们的并集,还是Dst不相交的部分,最后覆盖到满月上,所以必须先画半圆

  4. 随着动画的变化,椭圆的长轴会发生变化,这样就可以实现不同的月相


诗歌打字机效果


上面其实已经做得差不多了,我们最后再添加一些诗歌,并为它们添加打字机效果


@Composable
fun PoetryColumn(
list: List<Char>,
offsetX: Float = 0f,
offsetY: Float = 0f
) {
val targetList = remember { mutableStateListOf<Char>() }
LaunchedEffect(list) {
targetList.clear()
list.forEach {
delay(500) //通过在LaunchedEffect中delay实现动画效果
targetList.add(it)
}
}
//将 Jetpack Compose 环境的 Paint 对象转换为原生的 Paint 对象
val textPaint = Paint().asFrameworkPaint().apply {
//...
}
Canvas(modifier = Modifier.wrapContentSize()) {
drawIntoCanvas {
for (i in targetList.indices) {
it.nativeCanvas.drawText(list[i].toString(), x, y, textPaint)
y += delta // 更新文字y轴位置
}
}
}
}

如上所示,代码比较简单



  1. 通过在LaunchedEffect中调用挂起函数,来实现动画效果

  2. 为了实现竖直方向的文字,我们需要使用Paint来绘制Text,而不能使用Text组件

  3. Compose目前还不支持直接绘制Text,所以我们需要调用asFrameworkPaint将其转化为原生的Paint


总结


通过以上步骤,我们就通过Compose实现了月相阴晴圆缺+星空闪耀+诗歌打字机的动画效果

开发起来跟Android自定义绘制其实并没有多大差别,代码量因为Compose强大的API与声明式特点可能还有所减少

在我看来,Compose已经相当成熟了,而且将是Android UI的未来~


开源不易,如果项目对你有所帮助,欢迎点赞,Star,收藏~


作者:RicardoMJiang
链接:https://juejin.cn/post/7007041238293544991
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

iOS KVO的基本使用

iOS
iOS - 关于 KVO 的一些总结1. 什么是 KVOKVO的全称是Key-Value Observing,俗称“键值观察/监听”,是苹果提供的一套事件通知机制,允许一个对象观察/监听另一个对象指定属性值的改变。当被观察对象属性值发生改变时,会触发KVO的监...
继续阅读 »

iOS - 关于 KVO 的一些总结

1. 什么是 KVO

  • KVO的全称是Key-Value Observing,俗称“键值观察/监听”,是苹果提供的一套事件通知机制,允许一个对象观察/监听另一个对象指定属性值的改变。当被观察对象属性值发生改变时,会触发KVO的监听方法来通知观察者。KVO是在MVC应用程序中的各层之间进行通信的一种特别有用的技术。
  • KVONSNotification都是iOS中观察者模式的一种实现。
  • KVO可以监听单个属性的变化,也可以监听集合对象的变化。监听集合对象变化时,需要通过KVCmutableArrayValueForKey:等可变代理方法获得集合代理对象,并使用代理对象进行操作,当代理对象的内部对象发生改变时,会触发KVO的监听方法。集合对象包含NSArrayNSSet
  • KVOKVC有着密切的关系,如果想要深入了解KVO,建议先学习KVC


传送门:iOS - 关于 KVC 的一些总结

2. KVO 的基本使用

KVO使用三部曲:添加/注册KVO监听、实现监听方法以接收属性改变通知、 移除KVO监听。

  1. 调用方法addObserver:forKeyPath:options:context: 给被观察对象添加观察者;
  2. 在观察者类中实现observeValueForKeyPath:ofObject:change:context:方法以接收属性改变的通知消息;
  3. 当观察者不需要再监听时,调用removeObserver:forKeyPath:方法将观察者移除。需要注意的是,至少需要在观察者销毁之前,调用此方法,否则可能会导致Crash

2.1 注册方法

/*
** target: 被观察对象
** observer:观察者对象
** keyPath: 被观察对象的属性的关键路径,不能为nil
** options: 观察的配置选项,包括观察的内容(枚举类型):
NSKeyValueObservingOptionNew:观察新值
NSKeyValueObservingOptionOld:观察旧值
NSKeyValueObservingOptionInitial:观察初始值,如果想在注册观察者后,立即接收一次回调,可以加入该枚举值
NSKeyValueObservingOptionPrior:分别在值改变前后触发方法(即一次修改有两次触发)
** context: 可以传入任意数据(任意类型的对象或者C指针),在监听方法中可以接收到这个数据,是KVO中的一种传值方式
如果传的是一个对象,必须在移除观察之前持有它的强引用,否则在监听方法中访问context就可能导致Crash
*/

- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options context:(nullable void *)context;

2.2 监听方法

如果对象被注册成为观察者,则该对象必须能响应以下监听方法,即该对象所属类中必须实现监听方法。当被观察对象属性发生改变时就会调用监听方法。如果没有实现就会导致Crash

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
/*
** keyPath:被观察对象的属性的关键路径
** object: 被观察对象
** change: 字典 NSDictionary,属性值更改的详细信息,根据注册方法中options参数传入的枚举来返回
key为 NSKeyValueChangeKey 枚举类型
{
1.NSKeyValueChangeKindKey:存储本次改变的信息(change字典中默认包含这个key)
{
对应枚举类型 NSKeyValueChange
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
NSKeyValueChangeSetting = 1,
NSKeyValueChangeInsertion = 2,
NSKeyValueChangeRemoval = 3,
NSKeyValueChangeReplacement = 4,
};
如果是对被观察对象属性(包括集合)进行赋值操作,kind 字段的值为 NSKeyValueChangeSetting
如果被观察的是集合对象,且进行的是(插入、删除、替换)操作,则会根据集合对象的操作方式来设置 kind 字段的值
插入:NSKeyValueChangeInsertion
删除:NSKeyValueChangeRemoval
替换:NSKeyValueChangeReplacement
}
2.NSKeyValueChangeNewKey:存储新值(如果options中传入NSKeyValueObservingOptionNew,change字典中就会包含这个key)
3.NSKeyValueChangeOldKey:存储旧值(如果options中传入NSKeyValueObservingOptionOld,change字典中就会包含这个key)
4.NSKeyValueChangeIndexesKey:如果被观察的是集合对象,且进行的是(插入、删除、替换)操作,则change字典中就会包含这个key,
这个key的value是一个NSIndexSet对象,包含更改关系中的索引
5.NSKeyValueChangeNotificationIsPriorKey:如果options中传入NSKeyValueObservingOptionPrior,则在改变前通知的change字典中会包含这个key。
这个key对应的value是NSNumber包装的YES,我们可以这样来判断是不是在改变前的通知[change[NSKeyValueChangeNotificationIsPriorKey] boolValue] == YES]
}
** context:注册方法中传入的context
*/

}

2.3 移除方法

在调用注册方法后,KVO并不会对观察者进行强引用,所以需要注意观察者的生命周期。至少需要在观察者销毁之前,调用以下方法移除观察者,否则如果在观察者被释放后,再次触发KVO监听方法就会导致Crash

- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context;

2.4 使用示例

以下使用KVOperson对象添加观察者为当前viewController,监听person对象的name属性值的改变。当name值改变时,触发KVO的监听方法。

- (void)viewDidLoad {
[super viewDidLoad];

self.person = [HTPerson new];
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld) context:NULL];
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
self.person.name= @"张三";
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
NSLog(@"keyPath:%@",keyPath);
NSLog(@"object:%@",object);
NSLog(@"change:%@",change);
NSLog(@"context:%@",context);
}

- (void)dealloc
{
[self.person removeObserver:self forKeyPath:@"name"];
}

keyPath:name
object:
change:{ kind = 1; new = "\U70b9\U51fb"; old = ""; }
context:(null)

2.5 实际应用

KVO主要用来做键值观察操作,想要一个值发生改变后通知另一个对象,则用KVO实现最为合适。斯坦福大学的iOS教程中有一个很经典的案例,通过KVOModelController之间进行通信。如图所示: 斯坦福大学 KVO示例

2.6 KVO 触发监听方法的方式

KVO触发分为自动触发和手动触发两种方式。

2.6.1 自动触发

① 如果是监听对象特定属性值的改变,通过以下方式改变属性值会触发KVO

  • 使用点语法
  • 使用setter方法
  • 使用KVCsetValue:forKey:方法
  • 使用KVCsetValue:forKeyPath:方法

② 如果是监听集合对象的改变,需要通过KVCmutableArrayValueForKey:等方法获得代理对象,并使用代理对象进行操作,当代理对象的内部对象发生改变时,会触发KVO。集合对象包含NSArrayNSSet

2.6.2 手动触发

① 普通对象属性或是成员变量使用:

- (void)willChangeValueForKey:(NSString *)key;
- (void)didChangeValueForKey:(NSString *)key;

② NSArray对象使用:

- (void)willChange:(NSKeyValueChange)changeKind valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key;
- (void)didChange:(NSKeyValueChange)changeKind valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key;

③ NSSet对象使用:

- (void)willChange:(NSKeyValueChange)changeKind valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key;
- (void)didChange:(NSKeyValueChange)changeKind valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key;


收起阅读 »

ios Category无法覆写系统方法?

iOS
Category无法覆写系统方法?这是一次非常有趣的解决问题经历,以至于我认为解决方式可能比问题本身更有意思,另一点就是人多力量大,多人讨论就会获得多种思路。首次提出这个问题的是反向抽烟,他遇到了不能用 Category 覆写系统方法的现象。问题抛到我这,我验...
继续阅读 »

Category无法覆写系统方法?

这是一次非常有趣的解决问题经历,以至于我认为解决方式可能比问题本身更有意思,另一点就是人多力量大,多人讨论就会获得多种思路。

首次提出这个问题的是反向抽烟,他遇到了不能用 Category 覆写系统方法的现象。问题抛到我这,我验证了这个有点奇怪的现象,并决定好好探究一下,重看了 Category 那部分源码仍没有找到合理解释,于是将这个问题抛到开发群里,最后由皮拉夫大王在此给出了最为合理的解释。之后我又顺着他的思路找到了一些更有力的证据。以下是这一过程的经历。

问题提出

以下内容出自反向抽烟:

背景:想为 UITextField 提供单独的属性 placeholderColor ,用来直接设置占位符的颜色,这个时候使用分类设置属性,重写 setter 和 getter,set中直接使用 KVC 的方式对属性的颜色赋值;这个时候就有个bug,如果在其他类中使用 UITextField 这个控件的时候,先设置颜色,再设置文字,会发现占位符的颜色没有发生改变。

解决思路:首先想到 UITextField 中的 Label 是使用的懒加载,当有文字设置的时候,就会初始化这个label,这时候就考虑先设置颜色根本就没起到作用;

解决办法:在分类中 placeholderColor 的 setter 方法中,使用runtime的objc_setAssociatedObject先把颜色保存起来,这样就能保证先设置的颜色不会丢掉,然后需要重写 placeholder的setter方法,让在设置完文字的时候,拿到先前保存的颜色,故要在placeholderColor 的getter中用objc_getAssociatedObject取,这里有个问题点,在分类中重写 placeholder 的setter方法的话,在外面设置 placeholder 的时候,根本不走自己重写的这个 setPlaceholder方法,而走系统自带的,这里我还没研究。然后为了解决这个问题,我自己写了个setDsyPlaceholder方法,在setDsyPlaceholder里面对标签赋值,同时添加已经保存好的颜色,然后与setPlaceholder做交换,bug修复。

这里大家先不要关注解决 placeholderColor 的方式是否正确,以免思路走偏。我们应该避免使用Category 覆写系统方法的,但这里引出了一个问题:如果就是要覆写系统的方法,为啥没被执行?

问题探索

我测试发现自定义类是可以通过 Category 覆写的,只有系统方法不可以。当时选的是 UIViewController 的viewDidLoad 方法,其他几个 UIViewController 方法也试了都不可以。

测试代码如下:

1
2
3
4
5
6
7
8
9
#import "UIViewController+Test.h"

@implementation UIViewController (Test)

- (void)viewDidLoad {
NSLog(@"viewDidLoad");
}

@end

所以猜测:系统方法被做了特殊处理都不能覆写,只有自定义类可以覆写

有一个解释是:系统方法是会被缓存的,方法查找走了缓存,没有查完整的方法表。

这个说法好像能说得通,但是系统缓存是库的层面,方法列表的缓存又是另一个维度了。方法列表的缓存应该是应用间独立进行的,这样才能保证不同应用对系统库的修改不会相互影响,所以这个解释站不住脚。

这时有朋友提出他们之前使用Category 覆写过 UIScreen 的 mainScreen,是可以成功的。我试了下确实可以,观察之后发现该属性是一个类属性。又试了其他几个系统库的类属性,也都是可以的。

所以猜测变成了:只有系统实例方法不能被覆写,类属性,类方法可以覆写

这时已经感觉奇怪了,这个规律也说不通。后来又有朋友测试通过 Xcode10.3 能够覆写系统方法,好嘛。。。

这时的猜测又变成了:苹果在某个特定版本开始才做了系统方法覆写的拦截

可靠的证据

皮拉夫大王在此提出了很关键的信息,他验证了iOS12系统可以覆写系统方法(后来验证iOS13状况相同),iOS14不能覆写。

但iOS14的情况并不是所有的系统方法都覆盖不了,能否覆盖与类方法还是实例方法无关。

例如:UIResponder的分类,重写init 和 isFirstResponderinit可以覆盖,isFirstResponder不能覆盖。在iOS14的系统上NS的类,很多都可以被分类覆盖,但是UIKit的类,在涉及到UI的方法时,很多都无法覆盖。

这里猜测:系统做了白名单,命中白名单的函数会被系统拦截和处理

以下是对 iOS14 状况的验证,覆写isFirstResponder,打印method_list

1
2
3
4
5
6
7
8
unsigned int count;
Method *list = class_copyMethodList(UIResponder.class, &count);
for (int i = 0; i < count; i++) {
Method m = list[i];
if ([NSStringFromSelector(method_getName(m)) isEqualToString:@"isFirstResponder"]) {
IMP imp = method_getImplementation(m);
}
}

isFirstResponder会命中两次,两次po imp的结果是:

1
2
3
4
//第一次
(libMainThreadChecker.dylib`__trampolines + 67272)
//第二次
(UIKitCore`-[UIResponder isFirstResponder])

同样的代码,在iOS12的设备也会命中两次,结果为:

1
2
3
4
//第一次
(SwiftDemo`-[UIResponder(xx) isFirstResponder] at WBOCTest.m:38)
//第二次
(UIKitCore`-[UIResponder isFirstResponder])

所以可以确认的是,分类方法是可以正常添加到系统类的,但在iOS14的系统中,覆写的方法却被libMainThreadChecker.dylib里的方法接管了,导致没有执行。

那么问题来了,这个libMainThreadChecker.dylib库是干嘛的,它做了什么?

这个库对应了Main Thread Checker这个功能,它是在Xcode9新增的,因为开销比较小,只占用1-2%的CPU,启动时间占用时间不到0.1s,所以被默认置为开的状态。它在调试期的作用是帮助我们定位那些应该在主线程执行,却没有放到主线程的代码执行情况。

另外官方文档还有一个解释

The Main Thread Checker tool dynamically replaces system methods that must execute on the main thread with variants that check the current thread. The tool replaces only system APIs with well-known thread requirements, and doesn’t replace all system APIs. Because the replacements occur in system frameworks, Main Thread Checker doesn’t require you to recompile your app.

这个家伙会动态的替换尝试重写需要在主线程执行的系统方法,但也不是所有的系统方法。

终于找到了!这很好的解释了为什么本应被覆盖的系统方法却指向了libMainTreadChecker.dylib这个库,同时也解释了为什么有些方法可以覆写,有些却不可以。

测试发现当我们关闭了这个开关,iOS14的设备就可以正常执行覆写的方法了。

到此基本完事了,但还留有一个小疑问,那就是为什么iOS14之前的设备,不受这个开关的影响?目前没有找到实质的证据表明苹果是如何处理的,但可以肯定的是跟 Main Thread Checker 这个功能有关。

总结

稍微抽象下一开始处理问题的方式:遇到问题 -> 猜想 -> 佐证 -> 推翻猜想 -> 重新猜想 -> 再佐证。

这其实是错误的流程,猜想和佐证可以,但他们一般只会成为一个验证的样例,而不能带给我们答案。所以正确的处理方式是,不要把太多时间浪费在猜想和佐证猜想上,而应该去深挖问题本身。新的解题思路可以是这样的:遇到问题 -> 猜想 -> 深挖 -> 根据挖到的点佐证结果。

链接:https://zhangferry.com/2021/04/21/overwrite_system_category/

收起阅读 »

iOS14开发-网络

iOS
基础知识App如何通过网络请求数据?App 通过一个 URL 向特定的主机发送一个网络请求加载需要的资源。URL 一般是使用 HTTP(HTTPS)协议,该协议会通过 IP(或域名)定位到资源所在的主机,然后等待主机处理和响应。主机通过本次网络请求指...
继续阅读 »

基础知识

App如何通过网络请求数据?

客户服务器模型

  1. App 通过一个 URL 向特定的主机发送一个网络请求加载需要的资源。URL 一般是使用 HTTP(HTTPS)协议,该协议会通过 IP(或域名)定位到资源所在的主机,然后等待主机处理和响应。
  2. 主机通过本次网络请求指定的端口号找到对应的处理软件,然后将网络请求转发给该软件进行处理(处理的软件会运行在特定的端口)。针对 HTTP(HTTPS)请求,处理的软件会随着开发语言的不同而不同,如 Java 的 Tomcat、PHP 的 Apache、.net 的 IIS、Node.js 的 JavaScript 运行时等)
  3. 处理软件针对本次请求进行分析,分析的内容包括请求的方法、路径以及携带的参数等。然后根据这些信息,进行相应的业务逻辑处理,最后通过主机将处理后的数据返回(返回的数据一般为 JSON 字符串)。
  4. App 接收到主机返回的数据,进行解析处理,最后展示到界面上。
  5. 发送请求获取资源的一方称为客户端。接收请求提供服务的一方称为服务端

基本概念

URL

  • Uniform Resource Locator(统一资源定位符),表示网络资源的地址或位置。
  • 互联网上的每个资源都有一个唯一的 URL,通过它能找到该资源。
  • URL 的基本格式协议://主机地址/路径

HTTP/HTTPS

  • HTTP—HyperTextTransferProtocol:超文本传输协议。
  • HTTPS—Hyper Text Transfer Protocol over Secure Socket Layer 或 Hypertext Transfer Protocol Secure:超文本传输安全协议。

请求方法

  • 在 HTTP/1.1 协议中,定义了 8 种发送 HTTP 请求的方法,分别是GET、POST、HEAD、PUT、DELETE、OPTIONS、TRACE、CONNECT
  • 最常用的是 GET 与 POST

响应状态码

状态码描述含义
200Ok请求成功
400Bad Request客户端请求的语法出现错误,服务端无法解析
404Not Found服务端无法根据客户端的请求找到对应的资源
500Internal Server Error服务端内部出现问题,无法完成响应

请求响应过程

请求响应过程

JSON

  • JavaScript Object Notation。
  • 一种轻量级的数据格式,一般用于数据交互。
  • 服务端返回给 App 客户端的数据,一般都是 JSON 格式。

语法

  • 数据以键值对key : value形式存在。
  • 多个数据由,分隔。
  • 花括号{}保存对象。
  • 方括号[]保存数组。

key与value

  • 标准 JSON 数据的 key 必须用双引号""
  • JSON 数据的 value 类型:
    • 数字(整数或浮点数)
    • 字符串("表示)
    • 布尔值(true 或 false)
    • 数组([]表示)
    • 对象({}表示)
    • null

解析

  • 厘清当前 JSON 数据的层级关系(借助于格式化工具)。
  • 明确每个 key 对应的 value 值的类型。
  • 解析技术
    • Codable 协议(推荐)。
    • JSONSerialization。
    • 第三方框架。

URLSession

使用步骤

  1. 创建请求资源的 URL。
  2. 创建 URLRequest,设置请求参数。
  3. 创建 URLSessionConfiguration 用于设置 URLSession 的工作模式和网络设置。
  4. 创建 URLSession。
  5. 通过 URLSession 构建 URLSessionTask,共有 3 种任务。 (1)URLSessionDataTask:请求数据的 Task。  (2)URLSessionUploadTask:上传数据的 Task。 (3)URLSessionDownloadTask:下载数据的 Task。 
  6. 启动任务。
  7. 处理服务端响应,有 2 种方式。 (1)通过 completionHandler(闭包)处理服务端响应。 (2)通过 URLSessionDataDelegate(代理)处理请求与响应过程的事件和接收服务端返回的数据。

基本使用

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// get()
// post()
}

func get() {
// 1. 确定URL
let url = URL(string: "http://v.juhe.cn/toutiao/index?type=top&key=申请的key")
// 2. 创建请求
let urlRequest = URLRequest(url: url!)
// cachePolicy: 缓存策略,App最常用的缓存策略是returnCacheDataElseLoad,表示先查看缓存数据,没有缓存再请求
// timeoutInterval:超时时间
// let urlRequest = URLRequest(url: url!, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 5)
let config = URLSessionConfiguration.default
// 3. 创建URLSession
let session = URLSession(configuration: config)
// 4. 创建任务
let task = session.dataTask(with: urlRequest) { data, _, error in
if error != nil {
print(error!)
} else {
if let data = data {
print(String(data: data, encoding: .utf8)!)
}
}
}
// 5. 启动任务
task.resume()
}

func post() {
let url = URL(string: "http://v.juhe.cn/toutiao/index")
var urlRequest = URLRequest(url: url!)
// 指明请求方法
urlRequest.httpMethod = "POST"
// 指明参数
let params = "type=top&key=申请的key"
// 设置请求体
urlRequest.httpBody = params.data(using: .utf8)
let config = URLSessionConfiguration.default
// delegateQueue决定了代理方法在哪个线程中执行
let session = URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue())
let task = session.dataTask(with: urlRequest)
task.resume()
}
}

// MARK:- URLSessionDataDelegate
extension ViewController: URLSessionDataDelegate {
// 开始接收数据
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
// 允许接收服务器的数据,默认情况下请求之后不接收服务器的数据即不会调用后面获取数据的代理方法
completionHandler(URLSession.ResponseDisposition.allow)
}

// 获取数据
// 根据请求的数据量该方法可能会调用多次,这样data返回的就是总数据的一段,此时需要用一个全局的Data进行追加存储
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
let result = String(data: data, encoding: .utf8)
if let result = result {
print(result)
}
}

// 获取结束
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
print(error)
} else {
print("=======成功=======")
}
}
}

注意:如果网络请求是 HTTP 而非 HTTPS,默认情况下,iOS 会阻断该请求,此时需要在 Info.plist 中进行如下配置。

<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>

URL转码与解码

  • 当请求参数带中文时,必须进行转码操作。
let url = "https://www.baidu.com?name=张三"
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
print(url) // URL中文转码
print(url.removingPercentEncoding!) // URL中文解码

  • 有时候只需要对URL中的中文处理,而不需要针对整个URL。
let str = "阿楚姑娘"
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
let url = URL(string: "https://music.163.com/#/search/m/?s=\(str)&type=1")

下载数据

class ViewController: UIViewController {
// 下载进度
@IBOutlet var downloadProgress: UIProgressView!
// 下载图片
@IBOutlet var downloadImageView: UIImageView!

override func viewDidLoad() {
super.viewDidLoad()

download()
}

func download() {
let url = URL(string: "http://172.20.53.240:8080/AppTestAPI/wall.png")!
let request = URLRequest(url: url)
let session = URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: OperationQueue())
let task = session.downloadTask(with: request)
task.resume()
}
}

extension ViewController: URLSessionDownloadDelegate {
// 下载完成
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
// 存入沙盒
let savePath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!
// 文件类型根据下载的内容决定
let fileName = "\(Int(Date().timeIntervalSince1970)).png"
let filePath = savePath + "/" + fileName
print(filePath)
do {
try FileManager.default.moveItem(at: location, to: URL(fileURLWithPath: filePath))
// 显示到界面
DispatchQueue.main.async {
self.downloadImageView.image = UIImage(contentsOfFile: filePath)
}
} catch {
print(error)
}
}

// 计算进度
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
DispatchQueue.main.async {
self.downloadProgress.setProgress(Float(totalBytesWritten) / Float(totalBytesExpectedToWrite), animated: true)
}
}
}

上传数据

上传数据需要服务端配合,不同的服务端代码可能会不一样,下面的上传代码适用于本人所写的服务端代码

  • 数据格式。

上传数据格式

  • 实现。
class ViewController: UIViewController {
let YFBoundary = "AnHuiWuHuYungFan"
@IBOutlet var uploadInfo: UILabel!
@IBOutlet var uploadProgress: UIProgressView!

override func viewDidLoad() {
super.viewDidLoad()

upload()
}

func upload() {
// 1. 确定URL
let url = URL(string: "http://172.20.53.240:8080/AppTestAPI/UploadServlet")!
// 2. 确定请求
var request = URLRequest(url: url)
// 3. 设置请求头
let head = "multipart/form-data;boundary=\(YFBoundary)"
request.setValue(head, forHTTPHeaderField: "Content-Type")
// 4. 设置请求方式
request.httpMethod = "POST"
// 5. 创建NSURLSession
let session = URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: OperationQueue())
// 6. 获取上传的数据(按照固定格式拼接)
var data = Data()
let header = headerString(mimeType: "image/png", uploadFile: "wall.png")
data.append(header.data(using: .utf8)!)
data.append(uploadData())
let tailer = tailerString()
data.append(tailer.data(using: .utf8)!)
// 7. 创建上传任务 上传的数据来自getData方法
let task = session.uploadTask(with: request, from: data) { _, _, error in
// 上传完毕后
if error != nil {
print(error!)
} else {
DispatchQueue.main.async {
self.uploadInfo.text = "上传成功"
}
}
}
// 8. 执行上传任务
task.resume()
}

// 开始标记
func headerString(mimeType: String, uploadFile: String) -> String {
var data = String()
// --Boundary\r\n
data.append("--" + YFBoundary + "\r\n")
// 文件参数名 Content-Disposition: form-data; name="myfile"; filename="wall.jpg"\r\n
data.append("Content-Disposition:form-data; name=\"myfile\";filename=\"\(uploadFile)\"\r\n")
// Content-Type 上传文件的类型 MIME\r\n\r\n
data.append("Content-Type:\(mimeType)\r\n\r\n")

return data
}

// 结束标记
func tailerString() -> String {
// \r\n--Boundary--\r\n
return "\r\n--" + YFBoundary + "--\r\n"
}

func uploadData() -> Data {
let image = UIImage(named: "wall.png")
let imageData = image!.pngData()
return imageData!
}
}

extension ViewController: URLSessionTaskDelegate {
// 上传进去
func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
DispatchQueue.main.async {
self.uploadProgress.setProgress(Float(totalBytesSent) / Float(totalBytesExpectedToSend), animated: true)
}
}

// 上传出错
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
print(error)
}
}
}

URLCache

  • 网络缓存有很多好处:节省流量、更快加载、断网可用。
  • 使用 URLCache 管理缓存区域的大小和数据。
  • 每一个 App 都默认创建了一个 URLCache 作为缓存管理者,可以通过URLCache.shared获取,也可以自定义。
// 创建URLCache
// memoryCapacity:内存缓存容量
// diskCapacity:硬盘缓存容量
// directory:硬盘缓存路径
let cache = URLCache(memoryCapacity: 10 * 1024 * 1024, diskCapacity: 100 * 1024 * 1024, directory: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first)
// 替换默认的缓存管理对象
URLCache.shared = cache

  • 常见属性与方法。
let url = URL(string: "http://v.juhe.cn/toutiao/index?type=top&key=申请的key")
let urlRequest = URLRequest(url: url!, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 5)
let cache = URLCache.shared

// 内存缓存大小
cache.memoryCapacity
// 硬盘缓存大小
cache.diskCapacity
// 已用内存缓存大小
cache.currentMemoryUsage
// 已用硬盘缓存大小
cache.currentDiskUsage
// 获取某个请求的缓存
let cacheResponse = cache.cachedResponse(for: urlRequest)
// 删除某个请求的缓存
cache.removeCachedResponse(for: urlRequest)
// 删除某个时间点开始的缓存
cache.removeCachedResponses(since: Date().addingTimeInterval(-60 * 60 * 48))
// 删除所有缓存
cache.removeAllCachedResponses()

WKWebView

  • 用于加载 Web 内容的控件。
  • 使用时必须导入WebKit模块。

基本使用

  • 加载网页。
// 创建URL
let url = URL(string: "https://www.abc.edu.cn")
// 创建URLRequest
let request = URLRequest(url: url!)
// 创建WKWebView
let webView = WKWebView(frame: UIScreen.main.bounds)
// 加载网页
webView.load(request)

  • 加载本地资源。
// 文件夹路径
let basePath = Bundle.main.path(forResource: "localWeb", ofType: nil)!
// 文件夹URL
let baseUrl = URL(fileURLWithPath: basePath, isDirectory: true)
// html路径
let filePath = basePath + "/index.html"
// 转成文件
let fileContent = try? NSString(contentsOfFile: filePath, encoding: String.Encoding.utf8.rawValue)
// 创建WKWebView
let webView = WKWebView(frame: UIScreen.main.bounds)
// 加载html
webView.loadHTMLString(fileContent! as String, baseURL: baseUrl)

注意:如果是本地资源是文件夹,拖进项目时,需要勾选Create folder references,然后用Bundle.main.path(forResource: "文件夹名", ofType: nil)获取资源路径。

与JavaScript交互

创建WKWebView

lazy var webView: WKWebView = {
// 创建WKPreferences
let preferences = WKPreferences()
// 开启JavaScript
preferences.javaScriptEnabled = true
// 创建WKWebViewConfiguration
let configuration = WKWebViewConfiguration()
// 设置WKWebViewConfiguration的WKPreferences
configuration.preferences = preferences
// 创建WKUserContentController
let userContentController = WKUserContentController()
// 配置WKWebViewConfiguration的WKUserContentController
configuration.userContentController = userContentController
// 给WKWebView与Swift交互起一个名字:callbackHandler,WKWebView给Swift发消息的时候会用到
// 此句要求实现WKScriptMessageHandler
configuration.userContentController.add(self, name: "callbackHandler")
// 创建WKWebView
var webView = WKWebView(frame: UIScreen.main.bounds, configuration: configuration)
// 让WKWebView翻动有回弹效果
webView.scrollView.bounces = true
// 只允许WKWebView上下滚动
webView.scrollView.alwaysBounceVertical = true
// 设置代理WKNavigationDelegate
webView.navigationDelegate = self
// 返回
return webView
}()

创建HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0,user-scalable=no"/>
</head>
<body>
iOS传过来的值:<span id="name"></span>
<button onclick="responseSwift()">响应iOS</button>
<script type="text/javascript">
// 给Swift调用
function sayHello(name) {
document.getElementById("name").innerHTML = name
return "Swift你也好!"
}
// 调用Swift方法
function responseSwift() {
// 这里的callbackHandler是创建WKWebViewConfiguration是定义的
window.webkit.messageHandlers.callbackHandler.postMessage("JavaScript发送消息给Swift")
}
</script>
</body>
</html>

两个协议

  • WKNavigationDelegate:判断页面加载完成,只有在页面加载完成后才能在实现 Swift 调用 JavaScript。WKWebView 调用 JavaScript:
// 加载完毕以后执行
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// 调用JavaScript方法
webView.evaluateJavaScript("sayHello('WebView你好!')") { (result, err) in
// result是JavaScript返回的值
print(result, err)
}
}

  • WKScriptMessageHandler:JavaScript 调用 Swift 时需要用到协议中的一个方法来。JavaScript 调用 WKWebView:
// Swift方法,可以在JavaScript中调用
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
print(message.body)
}

ViewController

class ViewController: UIViewController {
// 懒加载WKWebView
...

// 加载本地html
let html = try! String(contentsOfFile: Bundle.main.path(forResource: "index", ofType: "html")!, encoding: String.Encoding.utf8)

override func viewDidLoad() {
super.viewDidLoad()
// 标题
title = "WebView与JavaScript交互"
// 加载html
webView.loadHTMLString(html, baseURL: nil)
view.addSubview(webView)
}
}

// 遵守两个协议
extension ViewController: WKNavigationDelegate, WKScriptMessageHandler {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
...
}

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
...
}
}

SFSafariViewController

  • iOS 9 推出的一种 UIViewController,用于加载与显示 Web 内容,打开效果类似 Safari 浏览器的效果。
  • 使用时必须导入SafariServices模块。
import SafariServices

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
showSafariViewController()
}

func showSafariViewController() {
// URL
let url = URL(string: "https://www.baidu.com")
// 创建SFSafariViewController
let sf = SFSafariViewController(url: url!)
// 设置代理
sf.delegate = self
// 显示
present(sf, animated: true, completion: nil)
}
}

extension ViewController: SFSafariViewControllerDelegate {
// 点击左上角的完成(done)
func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
print(#function)
}

// 加载完成
func safariViewController(_ controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) {
print(#function)
}
}
收起阅读 »

精益求精!记一次业务代码的优化探索

关键词:需求实现、设计模式、策略模式、程序员成长 承启: 本篇从业务场景出发,介绍了面对一个复杂需求,拆解重难点、编码实现需求、优化代码、思考个人成长的过程。 会介绍一个运用策略模式的实战。 需求和编码本身小于打怪升级成长路径。 文中代码为伪代码。 场景...
继续阅读 »

关键词:需求实现、设计模式、策略模式、程序员成长



承启:


本篇从业务场景出发,介绍了面对一个复杂需求,拆解重难点、编码实现需求、优化代码、思考个人成长的过程。



  • 会介绍一个运用策略模式的实战。

  • 需求和编码本身小于打怪升级成长路径。

  • 文中代码为伪代码。


场景说明:


需求描述:手淘内“充值中心”要投放在饿了么、淘宝极速版、UC浏览器等集团二方APP。
拿到需求之后,来梳理下“充值中心”在他端投放涉及到的核心功能点



  • 通讯录读取


不同客户端、操作系统,JSbridge API实现略有不同。



  • 支付


不同端支付JSbridge调用方式不同。



  • 账号体系:


集团内不同端账号体系可能不同,需要打通。



  • 容器兼容


手淘内采用PHA容器,淘宝极简版本投放H5,饿了么以手淘小程序的方式投放。环境变量、通信方式等需要兼容。



  • 各端个性化诉求


极速版投放极简链路,只保留核心模块等。


解决方案


需求明确了:充值相关核心模块,需要兼容每个APP,本质是提供一个多端投放的解决方案
那么这个场景如何编码实现呢?


1、方案一


首先第一个想法💡,在每个功能点模块用if-else判断客户端环境,编写此端逻辑。
下面以获取通讯录列表功能为例,代码如下:


// 业务代码文件 index.js
/**
* 获取通讯录列表
* @param clientName 端名称
*/
const getContactsList = (clientName) => {
if (clientName === 'eleme') {
getContactsListEleme()
} else if (clientName === 'taobao') {
getContactsListTaobao()
} else if (clientName === 'tianmao') {
getContactsListTianmao()
} else if (clientName === 'zhifubao') {
getContactsListZhifubao()
} else {
// 其他端
}
}

写完之后,review一下代码,思考一下这样编码的利弊。


:逻辑清晰,可快速实现。

:代码不美观、可读性略差,每兼容一个端都要在业务逻辑处改动,改一端测多端。


这时,有的同学就说了:“把if-else改成switch-case的写法,把获取通讯录模块抽象成独立的sdk封装,用户在业务层统一调用”,天才!动手实现一下。


2、方案二


核心功能模块,抽象成独立的sdk,模块内部对不同的端进行兼容,业务逻辑里统一方式调用。


/**
* 获取通讯录列表 sdk caontact.js
* @param clientName 端名称
* @param successCallback 成功回调
* @param failCallback 失败回调
*/
export default function (clientName, successCallback, failCallback) {
switch (clientName) {
case 'eleme':
getContactsListEleme()
break
case 'taobao':
getContactsListTaobao()
break
case 'zhifubao':
getContactsListTianmao()
break
case 'tianmao':
getContactsListZhifubao()
break
default:
// 省略
break
}
}

// 业务调用 index.js
<Contacts onIconClick={handleContactsClick} />

import getContactsList from 'Contacts'
import { clientName } from 'env'
const handleContactsClick = () => {
getContactsList(
clientName,
({ arr }) => {
this.setState({
contactsList: arr
})
},
() => {
alert('获取通讯录失败')
}
)
}

惯例,review一下代码:


:模块分工明确,业务层统一调用,代码可读性较高。

:多端没有解藕,每次迭代,需要各个端回归。


上面的实现,看起来代码可读性提高了不少,是一个不错的设计,可是这样是最优的设计吗?


3、方案三


熟悉设计模式的同学,这时候可能要说了,用策略模式啊,对了,这个场景可以用策略模式。
这里简单解释一下策略模式:
策略模式,英文全称是 Strategy Design Pattern。
在 GoF 的《设计模式》一书中,它是这样定义的:



Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.



翻译成中文就是:定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码)。


难免有些晦涩,什么意思呢?我个人的理解为:策略模式用来解耦策略的定义、创建、使用。它典型的应用场景就是:避免冗长的if-else或switch分支判断编码。


下面看代码实现:


/**
* 策略定义
*/
const strategies = {
eleme: () => {
getContactsListEleme()
},
taobao: () => {
getContactsListTaobao()
},
tianmao: () => {
// 省略
}
}
/**
* 策略创建
*/
const getContactsStrategy = (clientName) => {
if (!clientName) {
throw new Error('clientName is empty.')
}
return strategies[clientName]
}
/**
* 策略使用
*/
import { clientName } from 'env'
getContactsStrategy(clientName)()

策略模式的运用,把策略的定义、创建、使用解耦,符合设计原则中的迪米特法则(LOD),实现“高内聚、松耦合”。
当需要新增一个适配端时,我们只需要修改策略定义Map,其他代码都不需要修改,这样就将代码改动最小化、集中化了。


能做到这里,相信你已经超越了一部分同学了,但是我们还要思考、精益求精,如何更优呢?这个时候单从编码层面思考已经受阻塞了,可否从工程构建角度、性能优化角度、项目迭代流程角度、后期代码维护角度思考一下,相信你会有更好的想法。


下面抛砖,聊聊我自己的思考:


4、方案四


从工程构建和性能优化角度出发:如果每个端独立一个文件,构建的时候shake掉其他端chunk,这样bundle可以变更小,网络请求也变更快。



等等... Tree-Shaking是基于ES静态分析,我们的策略判断,基于运行时,好像没什么用啊。



方案三使用策略模式来编码,本质是策略定义、创建和使用解藕,那可否使用刚才的想法,把每端各个功能模块兼容方法聚合成独立module,从更高维度,将多端业务策略定义、创建和使用解藕?



思考一下这样做的收益是什么?



因为每个端的适配,聚合在一个module,将多端业务策略解藕,某个端策略变更,只需要修改此端module,代码改动较小,且后续测试链路,不需要重复回归其他端。符合“高内聚、松耦合”。


代码实现:


/**
* 饿了么端策略定义module
*/
export const elmcStrategies = {
contacts: () => {
getContactsListEleme()
},
pay: () => {
payEleme()
},
// 其他功能略
}
/**
* 手淘端策略定义module
*/
export const tbStrategies = {
contacts: () => {
getContactsListTaobao()
},
pay: () => {
payTaobao()
},
// 其他功能略
};
// ...... (其他端略)
/**
* 策略创建 index.js
*/
import tbStrategies from './tbStrategies'
import elmcStrategies from './elmcStrategies'
export const getClientStrategy = (clientName) => {
const strategies = {
elmc: elmcStrategies,
tb: tbStrategies
// ...
}
if (!clientName) {
throw new Error('clientName is empty.')
}
return strategies[clientName]
};
/**
* 策略使用 pay
*/
import { clientName } from 'env'
getClientStrategy(clientName).pay()

代码目录如下图所示:index.js是多端策略的入口,其他文件为各端策略实现。




从方案四的推导来看,有时候,判断不一定是对的,但是从多个维度去思考,会打开思路,这时,更优方案往往就找上门来了~



5、方案五


既要解决眼前痛点,也要长远谋划,基于以上四种方案,再深入思考一步,如果业务有投放在第三方(非集团APP)的需求,比如投放在商家APP,且商家APP获取通讯录、支付逻辑等复杂多变,这个时候如何设计编码呢?
例如:拉起别端的唤端策略,受多方因素影响,涉及到产品壁垒,策略攻防,怎样控制代码改动次数,及时提高唤端率呢?
在这里简单抛砖,可以借助近几年很火的serverless,搭建唤端策略的faas函数,动态获取最优唤端策略,是不是一个好的方案呢?


沉淀&思考


以上针对多端兼容的问题,我们学习并运用了设计模式——策略模式。那么我们再来看看策略模式的设计思想是什么:

一提到策略模式,有人就觉得,它的作用是避免 if-else 分支判断逻辑。实际上,这种认识是很片面的。策略模式主要的作用还是解耦策略的定义、创建和使用,控制代码的复杂度,让每个部分都不至于过于复杂、代码量过多。除此之外,对于复杂代码来说,策略模式还能让其满足开闭原则,添加新策略的时候,最小化、集中化代码改动,减少引入 bug 的风险。

实际上,设计原则和思想比设计模式更加普适和重要。掌握了代码的设计原则和思想,我们能更清楚的了解,为什么要用某种设计模式,就能更恰到好处地应用设计模式。

还有一点需要注意,在代码设计时,应该了解他的业务价值和复杂度,避免过度设计,如果一个if-else可以解决的问题,何必大费周折,阔谈设计模式呢?


总结


理一下全文的核心路径,也是我此篇文章想要主要传达的打怪升级成长路径。


接到一个复杂的需求--> 理清需求 --> 拆解技术难点 --> 编码实现 --> 代码优化 --> 设计模式和设计原则学习 --> 举一反三 --> 记录沉淀。


当下,前端工程师在工作中,难免会陷入业务漩涡中,被业务推着走。面对这种风险,我们要思考如何在保障完成业务迭代的基础上,运用适合的技术架构,抽象出通用解决方案,沉淀落地。这样,既能帮助业务更快更稳定增长,又能在这个过程中收获个人成长。


作者:喜橙
链接:https://juejin.cn/post/7006136807263830029

收起阅读 »

使用CSS实现中秋民风民俗-拜月

前言 好像有些粗糙,哈哈哈哈。图片是网络的,我用我浅薄的Photoshop知识做了简单的处理。 看了一圈,感觉大家都好🐂 🍺,有做日地月公转的,有做月全食的,有做日落月出的,等等。可谓是八仙过海,各显神通,通览下来真是“精彩”渐欲迷人眼,但是好像没有做拜月的...
继续阅读 »

前言


image.png
好像有些粗糙,哈哈哈哈。图片是网络的,我用我浅薄的Photoshop知识做了简单的处理。


看了一圈,感觉大家都好🐂 🍺,有做日地月公转的,有做月全食的,有做日落月出的,等等。可谓是八仙过海,各显神通,通览下来真是“精彩”渐欲迷人眼,但是好像没有做拜月的,那我来吧。


拜月,在我国是一种十分古老的习俗,实际上是源自我国一些地方古人对“月神”的一种崇拜活动。中秋节是上古天象崇拜——敬月习俗的遗痕,祭月作为中秋节重要的祭礼之一,从古代延续至今,逐渐演化为民间的赏月、颂月活动,同时也成为现代人渴望团聚、寄托对生活美好愿望的主要形态。「以上来自百度百科」


不知道大家那边有没有这个习俗,我老家是有的,每次拜月都会准备很多好吃的,可把我高兴坏了,因为第二天就是我生日,也就是八月十六,所以好吃的贼多。


废话不多说,开始进入正题,源码在这里:中秋拜月


HTML


一个大的div里面套了三个div,分别代表月亮,月亮上的嫦娥玉兔,月亮下的拜月人群。


<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>中秋拜月</title>
</head>

<body>
<div class="background">
<div class="moon"></div>
<div class="change"></div>
<div class="table"></div>
</div>
</body>

CSS


星星背景


背景图片是从某位大佬的那里获取的,自己作图能力不行,流汗。很常规,设置了一个长宽适应全屏幕,然后就是背景图。


body {
margin: 0;
}

.background {
width: 100%;
height: calc(100vh);
background: #000 url("https://test-jou.oss-cn-beijing.aliyuncs.com/3760b5e2cc46f556.png") repeat top center;
z-index: 1;
}

image.png


月亮


月亮的颜色用的是#ff0,搭配了一个相符的阴影,之所以没有用白色,是我感觉主色调用黄色更能显示拜月的“神圣性”「非迷信」


 .moon {
position: absolute;
left: 108px;
top: 81px;
width: 180px;
height: 180px;
background-color: #ff0;
border-radius: 50%;
box-shadow: 0 0 20px 20px rgba(247, 247, 9, 0.5);
}

image.png


嫦娥和月兔


把嫦娥和月兔放到月亮上,有种嫦娥就在注视人间的味道


.change {
background-image: url("https://test-jou.oss-cn-beijing.aliyuncs.com/unnamed.png");
background-repeat:no-repeat; background-size:100% 100%;-moz-background-size:100% 100%;
position: absolute;
left: 100px;
top: 81px;
width: 180px;
height: 180px;
z-index: 99;
}

image.png


拜月人群


我感觉这是最不搭配的图了,如有不适,敬请谅解


  .table{
background-image: url("https://test-jou.oss-cn-beijing.aliyuncs.com/table.png");
background-repeat:no-repeat; background-size:100% 100%;-moz-background-size:100% 100%;
bottom: 0;
width: 640px;
right: 0;
height: 450px;
position: absolute;
}

image.png


总结


好几年中秋没有回家了,也有可能是忙或者其他原因,家里近几年中秋也不拜月了。有的时候会很怀念小时候,长大了,小时候也回不去了,因为是后端的缘故,页面也不是很懂,留作纪念吧……


附言


以前,车马很远,书信很慢,一生只够爱一个人「有感」


作者:Jouzeyu
链接:https://juejin.cn/post/7007288677831278628

收起阅读 »

一顿操作,我把 Table 组件性能提升了十倍

背景 Table 表格组件在 Web 开发中的应用随处可见,不过当表格数据量大后,伴随而来的是性能问题:渲染的 DOM 太多,渲染和交互都会有一定程度的卡顿。 通常,我们有两种优化表格的方式:一种是分页,另一种是虚拟滚动。这两种方式的优化思路都是减少 DOM ...
继续阅读 »

背景


Table 表格组件在 Web 开发中的应用随处可见,不过当表格数据量大后,伴随而来的是性能问题:渲染的 DOM 太多,渲染和交互都会有一定程度的卡顿。


通常,我们有两种优化表格的方式:一种是分页,另一种是虚拟滚动。这两种方式的优化思路都是减少 DOM 渲染的数量。在我们公司的项目中,会选择分页的方式,因为虚拟滚动不能正确的读出行的数量,会有 Accessibility 的问题。


记得 19 年的时候,我在 Zoom 已经推行了基于 Vue.js 的前后端分离的优化方案,并且基于 ElementUI 组件库开发了 ZoomUI。其中我们在重构用户管理页面的时候使用了 ZoomUI 的 Table 组件替换了之前老的用 jQuery 开发的 Table 组件。


因为绝大部分场景 Table 组件都是分页的,所以并不会有性能问题。但是在某个特殊场景下:基于关键词的搜索,可能会出现 200 * 20 条结果且不分页的情况,且表格是有一列是带有 checkbox 的,也就是可以选中某些行进行操作。


当我们去点选其中一行时,发现过了好久才选中,有明显的卡顿感,而之前的 jQuery 版本却没有这类问题,这一比较令人大跌眼镜。难道好好的技术重构,却要牺牲用户体验吗?


Table 组件第一次优化尝试


既然有性能问题,那么我们的第一时间的思路应该是要找出产生性能问题的原因。


列展示优化


首先,ZoomUI 渲染的 DOM 数量是要多于 jQuery 渲染的 Table 的,因此第一个思考方向是让 Table 组件尽可能地减少 DOM 的渲染数量


20 列数据通常在屏幕下是展示不全的,老的 jQuery Table 实现很简单,底部有滚动条,而 ZoomUI 在这种列可滚动的场景下,支持了左右列的固定,这样在左右滑动过程中,可以固定某些列一直展示,用户体验更好,但这样的实现是有一定代价的。


想要实现这种固定列的布局,ElementUI 用了 6 个 table 标签来实现,那么为什么需要 6 个 table 标签呢?


首先,为了让 Table 组件支持丰富的表头功能,表头和表体都是各自用一个 table 标签来实现。因此对于一个表格来说,就会有 2 个 table 标签,那么再加上左侧 fixed 的表格,和右侧 fixed 的表格,总共有 6 个 table 标签。


在 ElementUI 实现中,左侧 fixed 表格和右侧 fixed 表格从 DOM 上都渲染了完整的列,然后从样式上控制它们的显隐:


element.png


element1.png


但这么实现是有性能浪费的,因为完全不需要渲染这么多列,实际上只需要渲染固定展示的列的 DOM,然后做好高度同步即可。ZoomUI 就是这么实现的,效果如下:


zoom-ui.png
当然,仅仅减少 fixed 表格渲染的列,性能的提升还不够明显,有没有办法在列的渲染这个维度继续优化呢?


这就是从业务层面的优化了,对于一个 20 列的表格,往往关键的列并没有多少,那么我们可不可以初次渲染仅仅渲染关键的列,其它列通过配置方式的渲染呢?


根据上述需求,我给 Table 组件添加了如下功能:


zoom-ui1.png


Table 组件新增一个 initDisplayedColumn 属性,通过它可以配置初次渲染的列,同时当用户修改了初次渲染的列,会在前端存储下来,便于下一次的渲染。


通过这种方式,我们就可以少渲染一些列。显然,列渲染少了,表格整体渲染的 DOM 数就会变少,对性能也会有一定的提升。


更新渲染的优化


当然,仅仅通过优化列的渲染还是不够的,我们遇到的问题是当点选某一行引起的渲染卡顿,为什么会引起卡顿呢?


为了定位该问题,我用 Table 组件创建了一个 1000 * 7 的表格,开启了 Chrome 的 Performance 面板记录 checkbox 点选前后的性能。


在经过几次 checkbox 选择框的点选后,可以看到如下火焰图:


element2.png


其中黄色部分是 Scripting 脚本的执行时间,紫色部分是 Rendering 所占的时间。我们再截取一次更新的过程:


element3.png


然后观察 JS 脚本执行的 Call Tree,发现时间主要花在了 Table 组件的更新渲染上


element4.png


我们发现组件的 render to vnode 花费的时间约 600ms;vnode patch to DOM 花费的时间约 160ms。


为什么会需要这么长时间呢,因为点选了 checkbox,在组件内部修改了其维护的选中状态数据,而整个组件的 render 过程中又访问了这个状态数据,因此当这个数据修改后,会引发整个组件的重新渲染。


而又由于有 1000 * 7 条数据,因此整个表格需要循环 1000 * 7 次去创建最内部的 td,整个过程就会耗时较长。


那么循环的内部是不是有优化的空间呢?对于 ElementUI 的 Table 组件,这里有非常大的优化空间。


其实优化思路主要参考我之前写的 《揭秘 Vue.js 九个性能优化技巧》 其中的 Local variables 技巧。举个例子,在 ElementUI 的 Table 组件中,在渲染每个 td 的时候,有这么一段代码:


const data = {
store: this.store,
_self: this.context || this.table.$vnode.context,
column: columnData,
row,
$index
}

这样的代码相信很多小伙伴随手就写了,但却忽视了其内部潜在的性能问题。


由于 Vue.js 响应式系统的设计,在每次访问 this.store 的时候,都会触发响应式数据内部的 getter 函数,进而执行它的依赖收集,当这段代码被循环了 1000 * 7 次,就会执行 this.store 7000 次的依赖收集,这就造成了性能的浪费,而真正的依赖收集只需要执行一次就足够了。


解决这个问题其实也并不难,由于 Table 组件中的 TableBody 组件是用 render 函数写的,我们可以在组件 render 函数的入口处定义一些局部变量:


render(h) {
const { store /*...*/} = this
const context = this.context || this.table.$vnode.context
}

然后在渲染整个 render 的过程中,把局部变量当作内部函数的参数传入,这样在内部渲染 td 的渲染中再次访问这些变量就不会触发依赖收集了:


rowRender({store, context, /* ...其它变量 */}) {
const data = {
store: store,
_self: context,
column: columnData,
row,
$index,
disableTransition,
isSelectedRow
}
}

通过这种方式,我们把类似的代码都做了修改,就实现了 TableBody 组件渲染函数内部访问这些响应式变量,只触发一次依赖收集的效果,从而优化了 render 的性能。


来看一下优化后的火焰图:


zoom-ui2.png


从面积上看似乎 Scripting 的执行时间变少了,我们再来看它一次更新所需要的 JS 执行时间:


zoom-ui3.png


我们发现组件的 render to vnode 花费的时间约 240ms;vnode patch to DOM 花费的时间约 127ms。


可以看到,ZoomUI Table 组件的 render 的时间和 update 的时间都要明显少于 ElementUI 的 Table 组件。render 时间减少是由于响应式变量依赖收集的时间大大减少,update 的时间的减少是因为 fixed 表格渲染的 DOM 数量减少。


从用户的角度来看,DOM 的更新除了 Scripting 的时间,还有 Rendering 的时间,它们是共享一个线程的,当然由于 ZoomUI Table 组件渲染的 DOM 数量更少,执行 Rendering 的时间也更短。


手写 benchmark


仅仅从 Performance 面板的测试并不是一个特别精确的 benchmark,我们可以针对 Table 组件手写一个 benchmark。


我们可以先创建一个按钮,去模拟 Table 组件的选中操作:


<div>
<zm-button @click="toggleSelection(computedData[1])
">切换第二行选中状态
</zm-button>
</div>
<div>
更新所需时间: {{ renderTime }}
</div>

然后实现这个 toggleSelection 函数:


methods: {
toggleSelection(row) {
const s = window.performance.now()
if (row) {
this.$refs.table.toggleRowSelection(row)
}
setTimeout(() => {
this.renderTime = (window.performance.now() - s).toFixed(2) + 'ms'
})
}
}

我们在点击事件的回调函数中,通过 window.performance.now() 记录起始时间,然后在 setTimeout 的回调函数中,再去通过时间差去计算整个更新渲染需要的时间。


由于 JS 的执行和 UI 渲染占用同一线程,因此在一个宏任务执行过程中,会执行这俩任务,而 setTimeout 0 会把对应的回调函数添加到下一个宏任务中,当该回调函数执行,说明上一个宏任务执行完毕,此时做时间差去计算性能是相对精确的。


基于手写的 benchmark 得到如下测试结果:


element5.png


ElementUI Table 组件一次更新的时间约为 900ms。


zoom-ui4.png


ZoomUI Table 组件一次更新的时间约为 280ms,相比于 ElementUI 的 Table 组件,性能提升了约三倍


v-memo 的启发


经过这一番优化,基本解决了文章开头提到的问题,在 200 * 20 的表格中去选中一列,已经并无明显的卡顿感了,但相比于 jQuery 实现的 Table,效果还是要差了一点。


虽然性能优化了三倍,但我还是有个心结:明明只更新了一行数据的选中状态,却还是重新渲染了整个表格,仍然需要在组件 render 的过程中执行多次的循环,在 patch 的过程中通过 diff 算法来对比更新。


最近我研究了 Vue.js 3.2 v-memo 的实现,看完源码后,我非常激动,因为发现这个优化技巧似乎可以应用到 ZoomUI 的 Table 组件中,尽管我们的组件库是基于 Vue 2 版本开发的。


我花了一个下午的时间,经过一番尝试,果然成功了,那么具体是怎么做的呢?先不着急,我们从 v-memo 的实现原理说起。


v-memo 的实现原理


v-memo 是 Vue.js 3.2 版本新增的指令,它可以用于普通标签,也可以用于列表,结合 v-for 使用,在官网文档中,有这么一段介绍:



v-memo 仅供性能敏感场景的针对性优化,会用到的场景应该很少。渲染 v-for 长列表 (长度大于 1000) 可能是它最有用的场景:



<div v-for="item in list" :key="item.id" v-memo="[item.id === selected]">
<p>ID: {{ item.id }} - selected: {{ item.id === selected }}</p>
<p>...more child nodes</p>
</div>


当组件的 selected 状态发生变化时,即使绝大多数 item 都没有发生任何变化,大量的 VNode 仍将被创建。此处使用的 v-memo 本质上代表着“仅在 item 从未选中变为选中时更新它,反之亦然”。这允许每个未受影响的 item 重用之前的 VNode,并完全跳过差异比较。注意,我们不需要把 item.id 包含在记忆依赖数组里面,因为 Vue 可以自动从 item:key 中把它推断出来。



其实说白了 v-memo 的核心就是复用 vnode,上述模板借助于在线模板编译工具,可以看到其对应的 render 函数:


import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, isMemoSame as _isMemoSame, withMemo as _withMemo } from "vue"

const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "...more child nodes", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
const _memo = ([item.id === _ctx.selected])
if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
const _item = (_openBlock(), _createElementBlock("div", {
key: item.id
}, [
_createElementVNode("p", null, "ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */),
_hoisted_1
]))
_item.memo = _memo
return _item
}, _cache, 0), 128 /* KEYED_FRAGMENT */))
}

基于 v-for 的列表内部是通过 renderList 函数来渲染的,来看它的实现:


function renderList(source, renderItem, cache, index) {
let ret
const cached = (cache && cache[index])
if (isArray(source) || isString(source)) {
ret = new Array(source.length)
for (let i = 0, l = source.length; i < l; i++) {
ret[i] = renderItem(source[i], i, undefined, cached && cached[i])
}
}
else if (typeof source === 'number') {
// source 是数字
}
else if (isObject(source)) {
// source 是对象
}
else {
ret = []
}
if (cache) {
cache[index] = ret
}
return ret
}

我们只分析 source,也就是列表 list 是数组的情况,对于每一个 item,会执行 renderItem 函数来渲染。


从生成的 render 函数中,可以看到 renderItem 的实现如下:


(item, __, ___, _cached) => {
const _memo = ([item.id === _ctx.selected])
if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
const _item = (_openBlock(), _createElementBlock("div", {
key: item.id
}, [
_createElementVNode("p", null, "ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */),
_hoisted_1
]))
_item.memo = _memo
return _item
}

renderItem 函数内部,维护了一个 _memo 变量,它就是用来判断是否从缓存里获取 vnode 的条件数组;而第四个参数 _cached 对应的就是 item 对应缓存的 vnode。接下来通过 isMemoSame 函数来判断 memo 是否相同,来看它的实现:


function isMemoSame(cached, memo) {
const prev = cached.memo
if (prev.length != memo.length) {
return false
}
for (let i = 0; i < prev.length; i++) {
if (prev[i] !== memo[i]) {
return false
}
}
// ...
return true
}

isMemoSame 函数内部会通过 cached.memo 拿到缓存的 memo,然后通过遍历对比每一个条件来判断和当前的 memo 是否相同。


而在 renderItem 函数的结尾,就会把 _memo 缓存到当前 itemvnode 中,便于下一次通过 isMemoSame 来判断这个 memo 是否相同,如果相同,说明该项没有变化,直接返回上一次缓存的 vnode


那么这个缓存的 vnode 具体存储到哪里呢,原来在初始化组件实例的时候,就设计了渲染缓存:


const instance = {
// ...
renderCache: []
}

然后在执行 render 函数的时候,把这个缓存当做第二个参数传入:


const { renderCache } = instance
result = normalizeVNode(
render.call(
proxyToUse,
proxyToUse,
renderCache,
props,
setupState,
data,
ctx
)
)

然后在执行 renderList 函数的时候,把 _cahce 作为第三个参数传入:


export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
// renderItem 实现
}, _cache, 0), 128 /* KEYED_FRAGMENT */))
}

所以实际上列表缓存的 vnode 都保留在 _cache 中,也就是 instance.renderCache 中。


那么为啥使用缓存的 vnode 就能优化 patch 过程呢,因为在 patch 函数执行的时候,如果遇到新旧 vnode 相同,就直接返回,什么也不用做了。


const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, slotScopeIds = null, optimized = false) => {
if(n1 === n2) {
return
}
// ...
}

显然,由于使用缓存的 vnode,它们指向同一个对象引用,直接返回,节约了后续执行 patch 过程的时间。


在 Table 组件的应用


v-memo 的优化思路很简单,就是复用缓存的 vnode,这是一种空间换时间的优化思路。


那么,前面我们提到在表格组件中选择状态没有变化的行,是不是也可以从缓存中获取呢?


顺着这思路,我给 Table 组件设计了 useMemo 这个 prop,它其实是专门用于有选择列的场景。


然后在 TableBody 组件的 created 钩子函数中,创建了用于缓存的对象:


created() {
if (this.table.useMemo) {
if (!this.table.rowKey) {
throw new Error('for useMemo, row-key is required.')
}
this.vnodeCache = []
}
}

这里之所以把 vnodeCache 定义到 created 钩子函数中,是因为它并不需要变成响应式对象。


另外注意,我们会根据每一行的 key 作为缓存的 key,因此 Table 组件的 rowKey 属性是必须的。


然后在渲染每一行的过程中,添加了 useMemo 相关的逻辑:


function rowRender({ /* 各种变量参数 */}) {
let memo
const key = this.getKeyOfRow({ row, rowIndex: $index, rowKey })
let cached
if (useMemo) {
cached = this.vnodeCache[key]
const currentSelection = store.states.selection
if (cached && !this.isRowSelectionChanged(row, cached.memo, currentSelection)) {
return cached
}
memo = currentSelection.slice()
}
// 渲染 row,返回对应的 vnode
const ret = rowVnode
if (useMemo && columns.length) {
ret.memo = memo
this.vnodeCache[key] = ret
}
return ret
}

这里的 memo 变量用于记录已选中的行数据,并且它也会在函数最后存储到 vnodememo,便于下一次的比对。


在每次渲染 rowvnode 前,会根据 row 对应的 key 尝试从缓存中取;如果缓存中存在,再通过 isRowSelectionChanged 来判断行的选中状态是否改变;如果没有改变,则直接返回缓存的 vnode


如果没有命中缓存或者是行选择状态改变,则会去重新渲染拿到新的 rowVnode,然后更新到 vnodeCache 中。


当然,这种实现相比于 v-memo 没有那么通用,只去对比行选中的状态而不去对比其它数据的变化。你可能会问,如果这一行某列的数据修改了,但选中状态没变,再走缓存不就不对了吗?


确实存在这个问题,但是在我们的使用场景中,遇到数据修改,是会发送一个异步请求到后端,然获取新的数据再来更新表格数据。因此我只需要观测表格数据的变化清空 vnodeCache 即可:


watch: {
'store.states.data'() {
if (this.table.useMemo) {
this.vnodeCache = []
}
}
}

此外,我们支持列的可选则渲染功能,以及在窗口发生变化时,隐藏列也可能发生变化,于是在这两种场景下,也需要清空 vnodeCache


watch:{
'store.states.columns'() {
if (this.table.useMemo) {
this.vnodeCache = []
}
},
columnsHidden(newVal, oldVal) {
if (this.table.useMemo && !valueEquals(newVal, oldVal)) {
this.vnodeCache = []
}
}
}

以上实现就是基于 v-memo 的思路实现表格组件的性能优化。我们从火焰图上看一下它的效果:


zoom-ui6.png


我们发现黄色的 Scripting 时间几乎没有了,再来看它一次更新所需要的 JS 执行时间:


zoom-ui7.png
我们发现组件的 render to vnode 花费的时间约 20ms;vnode patch to DOM 花费的时间约 1ms,整个更新渲染过程,JS 的执行时间大幅减少。


另外,我们通过 benchmark 测试,得到如下结果:


zoom-ui5.png
优化后,ZoomUI Table 组件一次更新的时间约为 80ms,相比于 ElementUI 的 Table 组件,性能提升了约十倍


这个优化效果还是相当惊人的,并且从性能上已经不输 jQuery Table 了,我两年的心结也随之解开了。


总结


Table 表格性能提升主要是三个方面:减少 DOM 数量、优化 render 过程以及复用 vnode。有些时候,我们还可以从业务角度思考,去做一些优化。


虽然 useMemo 的实现还比较粗糙,但它目前已满足我们的使用场景了,并且当数据量越大,渲染的行列数越多,这种优化效果就越明显。如果未来有更多的需求,更新迭代就好。


由于一些原因,我们公司仍然在使用 Vue 2,但这并不妨碍我去学习 Vue 3,了解它一些新特性的实现原理以及设计思想,能让我开拓不少思路。


从分析定位问题到最终解决问题,希望这篇文章能给你在组件的性能优化方面提供一些思路,并应用到日常工作中。


链接:https://juejin.cn/post/7007252464726458399

收起阅读 »

vue3+typescript 实现一个中秋RPG游戏

前言 又到了周末时光,在家闲着没事,花了两天时间去构思并制作一个中秋节相关的页面,首先技术栈接地气并且跟的上目前的新技术,所以我考虑使用Vue3+Typescript,其次是中秋主题,我想到的是嫦娥奔月的故事,既然是嫦娥奔月的话,那么页面就得有趣味性和游戏性....
继续阅读 »

前言


又到了周末时光,在家闲着没事,花了两天时间去构思并制作一个中秋节相关的页面,首先技术栈接地气并且跟的上目前的新技术,所以我考虑使用Vue3+Typescript,其次是中秋主题,我想到的是嫦娥奔月的故事,既然是嫦娥奔月的话,那么页面就得有趣味性和游戏性. 所以我最后选择做类似这种风格的页面.


ChMkJ1tpIkuINzThAAUqitDPvVkAAqf4wHB5i0ABSqi715.jpg


选择好了技术栈和制作主题和风格. 就直接开干了. 肝了一天, 以下是制作完成后的成果


GIF.gif


先说一下剧本,这个剧本是春光灿烂猪八戒后羿(二牛)嫦娥的人物角色加上东成西就大理段王爷飞升桥段. 还有最后一个鬼畜飞升的效果,我先说一下,这个是实在没找到可用的素材,只能凑合的用网上找来的这个动画. o(╥﹏╥)o 好了, 那么就开始说说, 我是怎么实现这个类游戏的页面动画效果的.


页面组织结构


页面使用vite创建出来, 文件的结构是这样的


image.png


由于页面只有一个场景,所以整个页面是放在APP.vue中写的. interface文件夹存放定义的一些接口对象. 组件里边划分出来了4个组件, 依次是



  1. dialogBox: 底部对话框组件

  2. lottie: 输入咒语后的一个彩蛋爆炸效果组件

  3. sprite 精灵图动画组件

  4. typed 输入咒语的打字效果组件


那么我们就按照页面出现的动画效果依次去讲一下吧.


精灵图动画


页面开头首先是二牛角色从左边走上桥头的动画. 这个动画我们先来分析一下, 首先是帧动画, 也就是走路的这个动作的效果, 其次是从左边走上桥头的这个位移动画. 那么我们先说一下帧动画


帧动画



“逐帧动画是一种常见的动画形式(Frame By Frame),其原理是在“连续的关键帧”中分解动画动作,也就是在时间轴的每帧上逐帧绘制不同的内容,使其连续播放而成动画



image.png


用我这个项目举例, 二牛走路的动画其实是一张图片在我们前端这张图也叫雪碧图,图上有4个动作,4个动作在不停的切换的时候,在我们人眼中就形成了走路的动效了. 好的,原理解释清楚了,那么我们现在看一下代码


  <div ref="spriteBox">
<div ref="sprite" class="sprite"></div>
</div>

页面的结构很简单, 就三行html代码, 外边包裹的html其实是用来做位移动画用的, 里边的sprite就是做帧动画的. 下面我们看一下javascript代码


// 样式位置
export interface positionInterface {
left?: string,
top?: string,
bottom?: string,
right?: string
}

export interface spriteInterface {
length: number, // 精灵图的长度
url: string, // 图片的路径
width: number, // 图片的宽度
height: number, // 图片的高度
scale?: number, // 缩放
endPosition: positionInterface // 动画结束站的位置
}

import { Ref } from "vue";
import { positionInterface, spriteInterface } from "../../interface";

/**
* 精灵图实现逐帧动画
* @param spriteObj 精灵对象
* @param target 精灵节点
* @param wrap 精灵父节点 [控制精灵移动]
* @param callback 图片加载好回调函数
* @param moveCallback 移动到对应位置的回调函数
*/
export function useFrameAnimation(
spriteObj: spriteInterface,
target: Ref,
wrap: Ref,
callback: Function,
moveCallback: Function
) {
const { width, length, url, endPosition } = spriteObj;
let index = 0;

var img = new Image();
img.src = url;
img.addEventListener("load", () => {
let time;
(function autoLoop() {
callback && callback();
// 如果到达了指定的位置的话,则停止
if (isEnd(wrap, endPosition)) {
if (time) {
clearTimeout(time);
time = null;
moveCallback && moveCallback();
return;
}
}
if (index >= length) {
index = 0;
}
target.value.style.backgroundPositionX = -(width * index) + "px";
index++;
// 使用setTimeout, requestFrameAnimation 是60HZ进行渲染,部分设备会卡,使用setTimeout可以手动控制渲染时间
time = setTimeout(autoLoop, 160);
})();
});

// 走到了对应的位置
function isEnd(wrap, endPosition: positionInterface) {
let keys = Object.keys(endPosition);
for (let key of keys) {
if (window.getComputedStyle(wrap.value)[key] === endPosition[key]) {
return true;
}
}
return false;
}
}

参数


useFrameAnimation 这个帧动画的函数, 函数参数先传递精灵图的描述对象,它主要描述精灵图上是有几个动作组成的,图片的地址是多少,图片在DOM节点上的对象,以及移动到指定位置后,传递给调用函数的父级的回调函数. 其实在代码中的注释也描述的很清楚了.


图片加载


我们在使用这张图片做帧动画的时候,首先得在这张图片是加载好之后再去处理的. 所以我们得先new Image, 然后给它赋值上src, 然后监听它的load事件,


循环切换动画


在load事件句柄内, 写了一个loop循环切换图片的backgroundPositionX属性达到页面动作图片的切换,由于是循环动画,如果动画走到了最后一张图片的时候,得切回第一张图片


添加回调函数钩子


在图片加载完成的时候,回调一个callback函数,告诉外边图片已经加载完成了,如果有一些需要图片加载完成的事情做的话,可以在这个回调函数里边去写. 代码里边还有一个isEnd函数, 去判断位移动画是否已经完成,如果位移动画完成了的话,则停止帧动画的循环,让它静止下来成为一张图片. 然后再执行moveCallback告诉调用函数的父级,位移动画已经执行完成了. 这个函数大致做的事情就是这些了.


位移动画


位移动画就比较简单了, 我们先看下代码:


<script lang="ts">
import {
computed,
defineComponent,
defineEmit,
PropType,
reactive,
ref,
toRefs,
watchEffect,
} from "vue";
import { spriteInterface } from "../../interface";
import { useFrameAnimation } from "./useFrameAnimation";

export default defineComponent({
props: {
action: {
type: Boolean,
default: false,
},
spriteObj: Object as PropType<spriteInterface>,
},
defineEmit: ["moveEnd"],
setup(props, { emit }) {
const spriteBox = ref(null);
const sprite = ref({ style: "" });
const spriteObj = reactive(props.spriteObj || {}) as spriteInterface;
const { width, height, url, length } = toRefs(spriteObj);
watchEffect(() => {
if (props.action) {
useFrameAnimation(
spriteObj,
sprite,
spriteBox,
() => {
triggerMove();
},
() => {
emit("moveEnd");
}
);
}
});
// 给宽度后边加上单位
const widthRef = computed(() => {
return width.value + "px";
});
// 给高度后边加上单位
const heightRef = computed(() => {
return height.value + "px";
});
// 给背景图片连接添加url
const urlImg = computed(() => {
return `url("${url.value}")`;
});
// 移动到目标位置
function triggerMove() {
if (spriteObj.scale || spriteObj.scale === 0) {
spriteBox.value.style.transform = `scale(${spriteObj.scale})`;
}
if (spriteObj.endPosition) {
Object.keys(spriteObj.endPosition).forEach((o) => {
if (spriteBox.value && sprite.value.style) {
spriteBox.value.style[o] = spriteObj.endPosition[o];
}
});
}
}
return {
widthRef,
heightRef,
urlImg,
length,
sprite,
spriteBox,
triggerMove,
};
},
});
</script>

代码中主要的是这个watchEffect, 根据使用精灵组件传递的props.action去开始决定是否开始帧动画,在调用我们上一段讲的useFrameAnimation函数后,第四个参数回调函数是图片加载完成,图片加载完成的时候,我们可以在这里做位移动画,也就是triggerMove,triggerMove函数里实际上就是把在spriteObj配置好的一些位置以及缩放信息放到对应的DOM节点上,要说动画的话,其实是css去做的. 在监听到位移动画结束后,传递给父级一个moveEnd自定义事件.


<style lang="scss" scoped>
.sprite {
width: v-bind(widthRef);
height: v-bind(heightRef);
background-image: v-bind(urlImg);
background-repeat: no-repeat;
background-position: 0;
background-size: cover;
}
</style>

这里的css只描述了关于精灵图的宽度高度和图片路径,上边这种写法v-bind是vue3后可以使用的一种方式,这样就可以把动态的变量直接写在CSS里边了, 用过的都说好~ 关于精灵图真正的动画效果是写在了APP.vue里边的css里


  .boy {
position: absolute;
bottom: 90px;
left: 10px;
transform: translate3d(0, 0, 0, 0);
transition: all 4s cubic-bezier(0.4, 1.07, 0.73, 0.72);
}
.girl {
position: absolute;
bottom: 155px;
right: 300px;
transform: translate3d(0, 0, 0, 0);
transition: all 4s cubic-bezier(0.4, 1.07, 0.73, 0.72);
}

上面描述了二牛嫦娥的初始位置,以及动效.


对话框组件


二牛走到嫦娥旁边后,APP.vue就通过前面说的moveEnd自定义事件知晓了动画结束,然后在动画结束后,弹出对话框. 对话的话, 其实就得先想好一个对话的剧本以及对话剧本的格式了.


对话剧本


const dialogueContent = [
{
avatar: "/images/rpg_male.png",
content: "二牛:嫦娥你终于肯和我约会了, 哈哈",
},
{
avatar: "/images/rpg_female.png",
content: "嫦娥:二牛对不起,我是从月宫来的,我不能和人间的你在一起!",
},
{
avatar: "/images/rpg_female.png",
content:
"嫦娥:今天是中秋节,我只有今天这个机会可以重新回月宫",
},
{
avatar: "/images/rpg_female.png",
content:
"嫦娥:回月宫的条件是找到真心人,让他念起咒语,我才能飞升!",
},
{
avatar: "/images/rpg_female.png",
content: "嫦娥:而你就是我的真心人,你可以帮我嘛?",
},
{
avatar: "/images/rpg_male.png",
content: "二牛:好的,我明白了! 我会帮你的.",
},
{
avatar: "/images/rpg_female.png",
content: "嫦娥:好的。 谢谢你!",
},
];

以上就是我这个小游戏的剧本了, 因为是别人先说一段,我再说一段,或者别人说了一段,再接着说一段. 这种的话,就是直接按照对话顺序写下来就好了, 然后我们在代码里边就可以通过点击时间的交互来按照顺序一个一个展现出来. 对话的结构主要就人物头像人物内容, 这里我为了省事,把人物的名称也直接在内容里边展现出来, 其实如果需要的话,可以提出来.


结构


我们先看一下它的html结构


  <div v-if="isShow" class="rpg-dialog" @click="increase">
<img :src="dialogue.avatar" class="rpg-dialog__role" />
<div class="rpg-dialog__body">
{{ contentRef.value }}
</div>
</div>

结构其实也很简单,里边就是一个头像和内容,我们用isShow去控制对话框的显示隐藏,用increase去走到下一个对话内容里边.


逻辑实现


    function increase() {
dialogueIndex.value++;
if (dialogueIndex.value >= dialogueArr.length) {
isShow.value = false;
emit("close");
return;
}
// 把下个内容做成打字的效果
contentRef.value = useType(dialogue.value.content);
}

increase方法里边也很简单,点击后,申明的索引(默认是0开始)+1,如果索引等于剧本的长度了的时候, 就把对话框关掉,然后给APP.vue一个close自定义事件, 如果小于剧本的长度的话,则走到下一个剧本内容,并且以打字的效果呈现. 也就是useType方法.


/**
* 打字效果
* @param { Object } content 打字的内容
*/
export default function useTyped(content: string): Ref<string> {
let time: any = null
let i:number = 0
let typed = ref('_')
function autoType() {
if (typed.value.length < content.length) {
time = setTimeout(() =>{
typed.value = content.slice(0, i+1) + '_'
i++
autoType()
}, 200)
} else {
clearTimeout(time)
typed.value = content
}
}
autoType()
return typed
}

打字效果实现也很简单,默认给一个_,然后逐一拿到字符串的每一个字符,一个一个的加在新字符串后边. 如果拿到完整的字符串的时候,则停止循环.


打字框(咒语)组件


在结束了剧本后, APP.vue会拿到组件跑出来的close自定义事件,在这里面,我们可以把诅咒组件给显示出来,


结构


<div v-if="isShow" class="typed-modal">
<div class="typed-box">
<div class="typed-oldFont">{{ incantation }}</div>
<div
@input="inputChange"
ref="incantainerRef"
contenteditable
class="typed-font"
>
{{ font }}
</div>
</div>
</div>

诅咒组件,这里的html结构,我们可以看一下,里边用到了contenteditable这个属性,设置了这个属性后,div就可以变的和输入框类似,我们可以直接在div上面的文字上自由修改. 所以我们就需要在用户修改的时候,监听它的input事件. incantation 这个放的就是底部的提示咒语, font放的就是我们需要输入的咒语.


逻辑实现


export default defineComponent({
components: {
ClickIcon,
},
emits: ["completeOver"],
setup(props, { emit }) {
const isShow = ref(true);
const lottie = ref(null);
const incantainerRef = ref(null);
const defaultOption = reactive(defaultOptions);
const incantation = ref("Happy Mid-autumn Day");
let font = ref("_");

nextTick(() => {
incantainerRef.value.focus();
});

function inputChange(e) {
let text = e.target.innerText.replace("_", "");
if (!incantation.value.startsWith(text)) {
e.target.innerText = font.value;
} else {
if (incantation.value.length === text.length) {
emit("completeOver");
font.value = text;
isShow.value = false;
lottie.value.toggle();
} else {
font.value = text + "_";
}
}
}

return {
font,
inputChange,
incantation,
incantainerRef,
defaultOption,
lottie,
isShow,
};
},
});
</script>

在组件弹窗的时候,我们用incantainerRef.value.focus();让它自动获取焦点. 在inputChange事件里边, 我们去判断输入的咒语是否和提示的咒语相同,如果不同的话,则无法继续输入, 并停留在输入正确的咒语上, 如果都输入正确了的话, 则会自动关闭咒语弹窗,并弹出一个类似恭喜通过的烟花效果. 传入一个completeOver自定义事件给APP.vue.


页面主题APP.vue


页面的话,其实就像一个导演了. 接收到演员的各种回馈后, 然后安排下一个演员就位


  setup() {
let isShow = ref(false); // 对话框窗口开关
let typedShow = ref(false); // 咒语窗口开关
let girlAction = ref(false); // 女孩动作开关, 导演喊一句action后,演员开始演绎
const boy = reactive(boyData);
const girl = reactive(girlData);
const dialogueArr = reactive(dialogueContent);
// 男孩移动动画结束
function boyMoveEnd() {
isShow.value = true;
}
// 完成输入咒语
function completeOver() {
girlAction.value = true;
}
function girlMoveEnd() {}
// 对话窗口关闭
function dialogClose() {
// 对话框关闭后,弹出咒语的窗口,二牛输入咒语后,嫦娥开始飞仙动作
typedShow.value = true;
}
return {
dialogueArr,
boy,
girl,
isShow,
boyMoveEnd,
girlMoveEnd,
girlAction,
dialogClose,
typedShow,
completeOver,
};

大家看看就好,其实没啥特别好说的.


写在最后


关于那个烟花效果的话,我就不讲了,因为我上次的文章如何在vue中使用Lottie已经详细的讲清楚了每一个细节. 并且这一次的这个组件其实就是复用的我这篇文章讲的这个自己封装的组件. 基本的效果就这些,如果大家有兴趣的话,可以参照在我这个基础上再加入一些细节在里边. 比如添加云彩动效,添加水波动效等. 需要源码的可以点这里看看 充实的一天就是过得这么快呀~ 大家下次再见咯. 提前祝大家中秋节快乐!.



链接:https://juejin.cn/post/7007011750746783757

收起阅读 »

LeetCode第一讲:哈希表相关讲解

哈希表简单说明哈希表的建立需要有哈希地址,那么哈希地址地址的生成需要一个哈希函数,什么是哈希函数呢?哈希函数就是一个精心设计好的函数,该函数可以计算出存储的数据要放在什么位置,举个例子说明:例:有4条电话数据:王二蛋 12345678985李狗蛋 115544...
继续阅读 »

哈希表简单说明

哈希表的建立需要有哈希地址,那么哈希地址地址的生成需要一个哈希函数,什么是哈希函数呢?
哈希函数就是一个精心设计好的函数,该函数可以计算出存储的数据要放在什么位置,举个例子说明:

例:有4条电话数据:
王二蛋 12345678985
李狗蛋 11554456555
赵二狗 18816848615
李桂花 15899484538

如果我想查找王二蛋的电话,我需要拿出这个列表,一个一个找。但我想要通过名字快速查找王二蛋如何做呢?

答:我构建一个哈希表,来快速查找。那么通过名字来存的话,我需要构建一套规则来定位数据的存储位置。那么我构建如下的函数 Addr = H(”姓名“的首字母 ASCII - 65 ),需要一个32大小数组来存储数据。

但是如果按照刚刚的设计,那么”李桂花“与”李狗蛋“的存储就会发生冲突,那么在设计哈希函数有如下几种方式(对于哈希表而言,冲突只能尽可能地少,无法完全避免。)

哈希表构建

1、直接定址法
例如:有一个从1到100岁的人口数字统计表,其中,年龄作为关键字,哈希函数取关键字自身。
2、数字分析法
有学生的生日数据如下:
年.月.日
75.10.03
75.11.23
76.03.02
76.07.12
75.04.21
76.02.15

经分析,第一位,第二位,第三位重复的可能性大,取这三位造成冲突的机会增加,所以尽量不取前三位,取后三位比较好。
3、平方取中法
取关键字平方后的中间几位为哈希地址。
4、折叠法
将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为哈希地址,这方法称为折叠法。
例如:每一种西文图书都有一个国际标准图书编号,它是一个10位的十进制数字,若要以它作关键字建立一个哈希表,当馆藏书种类不到10,000时,可采用此法构造一个四位数的哈希函数。
5、除留余数法
取关键字被某个不大于哈希表表长m的数p除后所得余数为哈希地址。
H(key)=key MOD p (p<=m)
6、随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即
H(key)=random(key),其中random为随机函数。通常用于关键字长度不等时采用此法。
若已知哈希函数及冲突处理方法,哈希表的建立步骤如下:
Step1. 取出一个数据元素的关键字key,计算其在哈希表中的存储地址D=H(key)。若存储地址为D的存储空间还没有被占用,则将该数据元素存入;否则发生冲突,执行Step2。
Step2. 根据规定的冲突处理方法,计算关键字为key的数据元素之下一个存储地址。若该存储地址的存储空间没有被占用,则存入;否则继续执行Step2,直到找出一个存储空间没有被占用的存储地址为止。

冲突处理

1、拉链法
拉出一个动态链表代替静态顺序存储结构,可以避免哈希函数的冲突,不过缺点就是链表的设计过于麻烦,增加了编程复杂度。此法可以完全避免哈希函数的冲突。
2、多哈希法
设计二种甚至多种哈希函数,可以避免冲突,但是冲突几率还是有的,函数设计的越好或越多都可以将几率降到最低(除非人品太差,否则几乎不可能冲突)。
3、开放地址法
开放地址法有一个公式:Hi=(H(key)+di) MOD m i=1,2,…,k(k<=m-1)
其中,m为哈希表的表长。di 是产生冲突的时候的增量序列。如果di值可能为1,2,3,…m-1,称线性探测再散列。
如果di取1,则每次冲突之后,向后移动1个位置.如果di取值可能为1,-1,4,-4,9,-9,16,-16,…kk,-kk(k<=m/2)
称二次探测再散列。如果di取值可能为伪随机数列。称伪随机探测再散列。
4、建域法
假设哈希函数的值域为[0,m-1],则设向量HashTable[0…m-1]为基本表,另外设立存储空间向量OverTable[0…v]用以存储发生冲突的记录。

题目1:

给定两个数组,编写一个函数来计算它们的交集。
示例 1:
输入:nums1 = [1,2,2,1], nums2 = [2,2]
输出:[2]

示例 2:
输入:nums1 = [4,9,5], nums2 = [9,4,9,8,4]
输出:[9,4]

说明:
输出结果中的每个元素一定是唯一的。
我们可以不考虑输出结果的顺序。

题目地址:https://leetcode-cn.com/problems/intersection-of-two-arrays
来源:力扣(LeetCode)

题目解答:

public static int[] intersection(int[] nums1, int[] nums2) {
Set<Integer> set = new HashSet<>();
for(int i :nums1){
set.add(i);
}
Set<Integer> set2 = new HashSet<>();
for(int j:nums2){
if(set.contains(j)){
set2.add(j);
}
}
int[] st = new int[set2.size()];
Object[] kk = set2.toArray();
for(int i=0;i<kk.length;i++){
st[i] = (int)kk[i];
}
return st;
}

解答说明:
该题由于要算交集,数组还有重复的可能行,那么就采用java中有的HashSet来处理问题,HashSet底层源码是实现了一个hashMap,HashMap实际上就是哈希表的实现,那么可以直接用hash表的特点来去重,再由hash表的特点来查询交集。算法实际时间复杂度为O(m+n) 空间复杂度:O(m+n)

题目2:

写代码,移除未排序链表中的重复节点。保留最开始出现的节点。
示例1:
输入:[1, 2, 3, 3, 2, 1]
输出:[1, 2, 3]

示例2:
输入:[1, 1, 1, 1, 2]
输出:[1, 2]
提示:

链表长度在[0, 20000]范围内。
链表元素在[0, 20000]范围内。
题目地址:https://leetcode-cn.com/problems/remove-duplicate-node-lcci
来源:力扣(LeetCode)

题目解答:

public ListNode removeDuplicateNodes(ListNode head) {
Set<Integer> set = new HashSet<>();
ListNode root = head;
ListNode temp = head;
ListNode pre = null;
while (temp!=null){
if(set.contains(temp.val)){
temp = temp.next;
pre.next = temp;
}else{
set.add(temp.val);
pre = temp;
temp = temp.next;
}
}
return root;
}

解答说明:
采用hash表的hash唯一性来做缓冲区,时间复杂度:O(n) 空间复杂度:O(n)

收起阅读 »

面试再也不怕 Handler 了,消息传递机制全解析

一、为什么要使用 Handler众所周知,Android 不允许在子线程中更新 UI。但是我们在子线程完成耗时的操作之后,需要对界面数据进行更新,又该怎么处理呢?这时候,我们可以使用 Handler 进行 UI 更新。值得注意的是,更新 UI 我们需要把 Me...
继续阅读 »

一、为什么要使用 Handler

众所周知,Android 不允许在子线程中更新 UI。但是我们在子线程完成耗时的操作之后,需要对界面数据进行更新,又该怎么处理呢?这时候,我们可以使用 Handler 进行 UI 更新。值得注意的是,更新 UI 我们需要把 Message 发送到主线程持有的 MessageQueue ,否则程序依然就会发生奔溃。

另外,除了更新 UI,Handler 是 Android 系统的消息传递机制,它定义了一套处理消息的规则,广播、服务以及线程间的通信都需要靠它来完成。

与 Handler 相关的还有 Looper 和 MessageQueue,接下来我们就从它的使用开始分析,对这三剑客一网打尽。

二、Handler 发送消息的流程

Handler 发送消息有两种方式,一种是 sendMessage 的方式,一种是 post 的方式,通过对源码的阅读,post 的方式其实是调用到了 sendMessage 的方式。那我们就来看看 sendMessage 的流程吧。通过调用 sendMessage,最终会走到下面方法中:

image.png

这里做的事情很简单,必须满足 MessageQueue 不能为空,否则程序会抛出异常,接下来看 enqueueMessage 的流程:

image.png

在这里完成了两个重要的流程:

  • 为 msg 的 target 赋值,msg.target = this,因此这个 target 就是调用的 sendMessage 的 Handler。(记住这里的重点)
  • 调用了 MessageQueue 的 enqueueMessage 方法。

到目前为止,流程来到了 MessageQueue 中。现在看 MessageQueue 的 enqueueMessage 方法。

三、MessageQueue 的工作流程

由于 enqueueMessage 的方法比较长,我们这里不截图,直接看下面的代码:(省略部分代码)

boolean enqueueMessage(Message msg, long when) {
// 1、target 不能为空,否则直接抛出异常
if (msg.target == null) {
throw new IllegalArgumentException("Message must have a target.");
}
// 2、加锁,不能有多个 Handler 同时发送消息
synchronized (this) {
msg.when = when;
Message p = mMessages; // 出队列的 msg 的下一个要出队列的 msg
boolean needWake;
// 3、下面这三种情况直接插在 head 节点上,(1)这个队列是一个空队列,
// (2)这个 msg 需要立即处理,(3)是它需要处理的时间比即将出队列的节
// 点的处理时间还要小
if (p == null || when == 0 || when < p.when) {
// New head, wake up the event queue if blocked.
msg.next = p;
mMessages = msg;
needWake = mBlocked;
} else {
needWake = mBlocked && p.target == null && msg.isAsynchronous();

// 4、如果之前第三点的条件不满足,就会从 head 节点开始遍历,
// 插入到一个合适的时间,或者链表的尾部,这个 for 循环做的其实就是
// 链表节点的插入
Message prev;
for (;;) {
prev = p;
p = p.next;
if (p == null || when < p.when) {
break;
}
if (needWake && p.isAsynchronous()) {
needWake = false;
}
}
msg.next = p; // invariant: p == prev.next
prev.next = msg;
}
// 5、是否需要进行唤醒,在 queue.next() 方法中如果没有获取到 msg就会休眠
if (needWake) {
nativeWake(mPtr);
}
}
return true;
}

解释其实已经在上面的代码里了,下面来做一个简单归纳:

  • MessageQueue 其本质上一个单向链表,入队列这个操作进行了加锁的处理,不能多个 msg 同时入队列。
  • 在插入队列的时候,会根据当前队列是否为空,或者处理消息的时间选择合适的插入位置。
  • 最后判断是否需要进行 wake up

到目前为止,我们看了 Handler 的发送消息的流程,以及消息是如何插入链表的,那么消息是如何处理的呢?我们知道,只有调用了 Looper 的 loop() 方法之后,才能处理消息,那接下来看 Looper 的 loop() 方法。

四、Looper 的工作流程

Looper 的 loop() 方法也是相当长,接下来看代码:(省略部分代码)

public static void loop() {
// 1、获取 Looper 对象,定进行判空处理
final Looper me = myLooper();
if (me == null) {
throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
}

// 2、获取了 MessageQueue 对象
final MessageQueue queue = me.mQueue;
for (;;) {
// 3、调用 MessageQueue 的 next(),返回值是 msg
Message msg = queue.next(); // might block
if (msg == null) {
return;
}
....
try {
// 4、之前说过,在 SendMessage 的时候设置了 msg 的target,这个 target 就是调用 sendMessage 的 Handler
msg.target.dispatchMessage(msg);
} catch (Exception exception) {
} finally {
}

msg.recycleUnchecked();
}
}

代码本身很长,但是其实做的事情也不多,现在简单归纳一下:

  • 在调用 Looper.loop() 之前,必须先调用 Looper.prepare(),如果没有 Looper 对象的话程序会直接抛异常。
  • 通过调用 MessageQueue 的 next 方法不断的从队列里取消息出来。
  • 最后把 msg 交给 Handler 的 dispatchMessage() 进行处理。

通过源码我们可以发现调用 queue.next() 时可能发生阻塞,那这个方法又做了什么?还有,为什么要先调用 Looper.prepare(),这个方法又做了什么处理?先来看比较简单的吧:

image.png

这个 Looper.prepare() 其实是创建了一个 Looper 对象,并且通过 ThreadLocal 实现每个线程有且仅有一个这样的 Looper 对象。为什么要创建 Looper 呢?没有就不行吗?我们来看 Handler 的构造函数:

image.png

可以看到,如果 Looper 为空的话,程序直接抛异常。这个 myLooper() 是用来获取当前线程的 Looper 对象:

image.png

从时序上说,我们调用 Looper.prepare() 的时机必须在 new Handler() 之前。那么,我们主线程使用 Handler 的时候,并没有调用 Looper.prepare() 这个方法,这又是怎么回事呢?

原来,在 ActivityThread 的 main() 方法中已经为我们进行了处理:

image.png

这个 prepareMainLooper() 在内部调用了 Looper.prepare() 。到目前为止,我们解决了 Looper 的相关问题,说明了必须存在 Looper 的原因。现在还有一个问题没有解决,queue.next() 方法做了什么事情?它为什么发生阻塞呢?

接下来看 MessageQueue 的 next() 方法:(已省略部分代码)

Message next() {

final long ptr = mPtr;
if (ptr == 0) {
return null;
}

int pendingIdleHandlerCount = -1;
int nextPollTimeoutMillis = 0;
for (;;) {
if (nextPollTimeoutMillis != 0) {
Binder.flushPendingCommands();
}
// 1、这是一个 native 方法,如果messageQueue 没有可以处理的消息就会休眠
nativePollOnce(ptr, nextPollTimeoutMillis);
synchronized (this) {
final long now = SystemClock.uptimeMillis();
Message prevMsg = null;
Message msg = mMessages;
if (msg != null && msg.target == null) {
// 2、同步屏障,寻找队列中的下一个异步消息
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
if (msg != null) {
if (now < msg.when) {
// 3、下一个出队列的这个 msg 还没有到时间,并计算需要阻塞的时间
nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
} else {
// 4、得到一个能够处理的msg,并返回这个 msg
mBlocked = false;
if (prevMsg != null) {
prevMsg.next = msg.next;
} else {
mMessages = msg.next;
}
msg.next = null;
if (DEBUG) Log.v(TAG, "Returning message: " + msg);
msg.markInUse();
return msg;
}
} else {
// No more messages.
nextPollTimeoutMillis = -1;
}
...
}
}
}

重要的点其实已经在上面说了,下面总结一下:

  • 获取 msg 的这个过程有可能会发生阻塞,具体调用到的是 native 的 nativePollOnce 方法
  • 获取消息的时候,有一个同步屏障,也就是对 msg 对应的 target(Handler) 为空的消息进行了过滤。
  • 如果能获取到一个 msg ,那么就返回这个 msg。

四、再看 Handler

先来梳理一下我们现在明白了什么:

  • 在创建 Handler 的时候,必须先创建 Looper 对象,之后还需要调用 Looper.loop() 方法才能让 Handler 开始工作。
  • 通过 Handler sendMessage 发送消息,其实是调用了 queue.enqueueMessage,这个 Queue 其实是一个单向链表,在调用这个方法的时候,会根据当前队列的转态以及 when 把这个 msg 插入到合适的位置。
  • queue.next() 可能会发生休眠,原因是拿到不到合适的 msg,在 queue.enqueueMessgae 的时候会判断是否需要唤醒。

之前我们说过,这个 msg 其实是交给了 Handler 的 dispatchMessage 去处理,下面来看一下 Handler 是怎么处理的:

image.png

  • msg.callback 是我们通过 post 方法传递进来的一个 Runnable 对象,如果我们没有使用 post 的话,就不会走到 handleCallback(msg) 中。
  • mCallback 是一个 CallBack 对象,如果我们在创建 Handler 的时候没有传这个参数,那么 mCallback 也是为null 的。
  • 最后才会走到 handleMessage(msg) 中。

收起阅读 »

在android中如何制作一个方向轮盘

先上效果图原理很简单,其实就是一个自定义的view通过观察,很容易发现,我们自己的轮盘就两个view需要绘制,一个是外面的圆盘,一个就随手指移动的滑块; 外面的圆盘很好绘制,内部的滑块则需要采集手指的位置,根据手指的位置计算出滑块在大圆内的位置; 最后,我们做...
继续阅读 »

先上效果图

Screenrecorder-2021-09-13-09-55-26-155.gif

原理很简单,其实就是一个自定义的view

通过观察,很容易发现,我们自己的轮盘就两个view需要绘制,一个是外面的圆盘,一个就随手指移动的滑块; 外面的圆盘很好绘制,内部的滑块则需要采集手指的位置,根据手指的位置计算出滑块在大圆内的位置; 最后,我们做的UI不是单纯做一个UI吧,肯定还是要用于实际应用中去,所以要加一个通用性很好的回调.

计算滑块位置的原理:

  • 当触摸点在大圆与小圆的半径差之内:
    那么滑块的位置就是触摸点的位置
  • 当触摸点在大圆与小圆的半径差之外:
    已知大圆圆心坐标(cx,cy),大圆半径rout,小圆半径rinside,触摸点的坐标(px,py)
    求小圆的圆心(ax,ay)?

image.png

作为经过九义的你我来说,这不就是一个简简单单的数学题嘛,很容易就求解出小圆的圆心位置了。 利用三角形相似:
\frac{ax-cx}{rout-rinside} = \frac{px-cx}{\sqrt{(px-cx)^2+(py-cy)^2}}
\frac{ay-cy}{rout-rinside} = \frac{py-cy}{\sqrt{(px-cx)^2+(py-cy)^2}}

通用性很好的接口:

滑块在圆中的位置,可以很好的用一个二位向量来表示,也可以用两个浮点的变量来表示;
xratio = \frac{ax-cx}{rout-rinside}
yratio = \frac{ay-cy}{rout-rinside}

这个接口就可以很好的表示了小圆在大圆的位置了,他们的取值范围是[-1,1]

小技巧:

为了小圆能始终在脱手后回到终点位置,我们设计了一个动画,当然,实际情况中有一种情况是,你移动到某个位置后,脱手后位置不能动,那你禁用这个动画即可。

代码部分

tips:代码部分的变量名与原理的变量名有出入

public class ControllerView extends View implements View.OnTouchListener {
private Paint borderPaint = new Paint();//大圆的画笔
private Paint fingerPaint = new Paint();//小圆的画笔
private float radius = 160;//默认大圆的半径
private float centerX = radius;//大圆中心点的位置cx
private float centerY = radius;//大圆中心点的位置cy
private float fingerX = centerX, fingerY = centerY;//小圆圆心的位置(ax,ay)
private float lastX = fingerX, lastY = fingerY;//小圆自动回归中点动画中上一点的位置
private float innerRadius = 30;//默认小圆半径
private float radiusBorder = (radius - innerRadius);//大圆减去小圆的半径
private ValueAnimator positionAnimator;//自动回中的动画
private MoveListener moveListener;//移动回调的接口

public ControllerView(Context context) {
super(context);
init(context, null, 0);
}

public ControllerView(Context context,
@Nullable AttributeSet attrs) {
super(context, attrs);
init(context, attrs, 0);
}

public ControllerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs, defStyleAttr);
}

//初始化
private void init(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
if (attrs != null) {
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.ControllerView);
int fingerColor = typedArray.getColor(R.styleable.ControllerView_fingerColor,
Color.parseColor("#3fffffff"));
int borderColor = typedArray.getColor(R.styleable.ControllerView_borderColor,
Color.GRAY);
radius = typedArray.getDimension(R.styleable.ControllerView_radius, 220);
innerRadius = typedArray.getDimension(R.styleable.ControllerView_fingerSize, innerRadius);
borderPaint.setColor(borderColor);
fingerPaint.setColor(fingerColor);
lastX = lastY = fingerX = fingerY = centerX = centerY = radius;
radiusBorder = radius - innerRadius;
typedArray.recycle();
}
setOnTouchListener(this);
positionAnimator = ValueAnimator.ofFloat(1);
positionAnimator.addUpdateListener(animation -> {
Float aFloat = (Float) animation.getAnimatedValue();
changeFingerPosition(lastX + (centerX - lastX) * aFloat, lastY + (centerY - lastY) * aFloat);
});
}

@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(getActualSpec(widthMeasureSpec), getActualSpec(heightMeasureSpec));
}


//处理wrapcontent的测量
//默认wrapcontent,没有做matchParent,指定大小的适配
//view实际的大小是通过大圆半径确定的
public int getActualSpec(int spec) {
int mode = MeasureSpec.getMode(spec);
int len = MeasureSpec.getSize(spec);
switch (mode) {
case MeasureSpec.AT_MOST:
len = (int) (radius * 2);
break;
}
return MeasureSpec.makeMeasureSpec(len, mode);
}

//绘制
@Override protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawCircle(centerX, centerY, radius, borderPaint);
canvas.drawCircle(fingerX, fingerY, innerRadius, fingerPaint);
}

@Override public boolean onTouch(View v, MotionEvent event) {
float evx = event.getX(), evy = event.getY();
float deltaX = evx - centerX, deltaY = evy - centerY;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//圆外按压不生效
if (deltaX * deltaX + deltaY * deltaY > radius * radius) {
break;
}
case MotionEvent.ACTION_MOVE:
//如果触摸点在圆外
if (Math.abs(deltaX) > radiusBorder || Math.abs(deltaY) > radiusBorder) {
float distance = (float) Math.sqrt(deltaX * deltaX + deltaY * deltaY);
changeFingerPosition(centerX + (deltaX * radiusBorder / distance),
centerY + (deltaY * radiusBorder / distance));
} else { //如果触摸点在圆内
changeFingerPosition(evx, evy);
}
positionAnimator.cancel();
break;
case MotionEvent.ACTION_UP:
positionAnimator.setDuration(1000);
positionAnimator.start();
break;
}
return true;
}

/**
* 改变位置的回调出来
*/

private void changeFingerPosition(float fingerX, float fingerY) {
this.fingerX = fingerX;
this.fingerY = fingerY;
if (moveListener != null) {
float r = radius - innerRadius;
if (r == 0) {
invalidate();
return;
}
moveListener.move((fingerX - centerX) / r, (fingerY - centerY) / r);
}
invalidate();
}

@Override protected void finalize() throws Throwable {
super.finalize();
positionAnimator.removeAllListeners();
}

public void setMoveListener(
MoveListener moveListener)
{
this.moveListener = moveListener;
}

/**
*回调事件的接口
*
**/

public interface MoveListener {
void move(float dx, float dy);
}
}

style.xml

name="ControllerView">
name="fingerColor" format="color" />
name="borderColor" format="color" />
name="fingerSize" format="dimension" />
name="radius" format="dimension" />



原文链接:https://juejin.cn/post/7007252815672279053
收起阅读 »

Android 架构师之路 - AOP 面向切面编程

引言相信很多做过Web的同学对AspectJ都不陌生,Spring的AOP就是基于它而来的。如果说平常我们随便写写程序的时候,基本也不会用到它,需要调试的话无非就是多加一个System.out.printfln()或者Log.d()。但是由于基于面向对象的固有...
继续阅读 »


引言

相信很多做过Web的同学对AspectJ都不陌生,Spring的AOP就是基于它而来的。如果说平常我们随便写写程序的时候,基本也不会用到它,需要调试的话无非就是多加一个System.out.printfln()或者Log.d()。但是由于基于面向对象的固有缺陷,导致很多同模块、同一水平上的工作要在许多类中重复出现。比如说:输出日志,监控方法执行时间,修改程序运行时的参数等等这样的事情,其实它们的代码都是可以重用的。

如果在一个大型的项目当中,使用手动修改源码的方式来达到调试、监控的目的,第一,需要插入许多重复代码(打印日志,监控方法执行时间),代码无法复用;第二,修改的成本太高,处处需要手动修改(分分钟累死、眼花)。

  • OOP: 面向对象把所有的事物都当做对象看待,因此每一个对象都有自己的生命周期,都是一个封装的整体。每一个对象都有自己的一套垂直的系列方法和属性,使得我们使用对象的时候不需要太多的关系它的内部细节和实现过程,只需要关注输入和输出,这跟我们的思维方式非常相近,极大的降低了我们的编写代码成本(而不像C那样让人头痛!)。但在现实世界中,并不是所有问题都能完美得划分到模块中。举个最简单而又常见的例子:现在想为每个模块加上日志功能,要求模块运行时候能输出日志。在不知道AOP的情况下,一般的处理都是:先设计一个日志输出模块,这个模块提供日志输出API,比如Android中的Log类。然后,其他模块需要输出日志的时候调用Log类的几个函数,比如e(TAG,…),w(TAG,…),d(TAG,…),i(TAG,…)等。
  • AOP: OOP固然开启另一个编程时代,但是久而久之也显露了它的缺点,最明显的一点就是它无法横向切割某一类方法、属性,当我们需要了解某一类方法、某一类属性的信息时,就必须要在每一个类的方法里面(即便他们是同样的方法,只因是不同的类所以不同)添加监控代码,在代码量庞大的情况下,这是一个不可取的方法。因此,AOP编产生了,基于AOP的编程可以让我们横向的切割某一类方法和属性(不需要关心他是什么类别!),AOP并不是与OOP对立的,而是为了弥补OOP的不足,因为有了AOP我们的调试和监控就变得简单清晰。

1.AspectJ介绍

1.1 AspectJ只是一个代码编译器

AspectJ 意思就是Java的Aspect,Java的AOP。它其实不是一个新的语言,它就是一个代码编译器(ajc,后面以此代替),在Java编译器的基础上增加了一些它自己的关键字识别和编译方法。因此,ajc也可以编译Java代码。它在编译期将开发者编写的Aspect程序编织到目标程序中,对目标程序作了重构,目的就是建立目标程序与Aspect程序的连接(耦合,获得对方的引用(获得的是声明类型,不是运行时类型)和上下文信息),从而达到AOP的目的(这里在编译期还是修改了原来程序的代码,但是是ajc替我们做的)。

1.2 AspectJ是用来做AOP编程的

Cross-cutting concerns(横切关注点): 尽管面向对象模型中大多数类会实现单一特定的功能,但通常也会开放一些通用的附属功能给其他类。例如,我们希望在数据访问层中的类中添加日志,同时也希望当UI层中一个线程进入或者退出调用一个方法时添加日志。尽管每个类都有一个区别于其他类的主要功能,但在代码里,仍然经常需要添加一些相同的附属功能。

  • Advice(通知): 注入到class文件中的代码。典型的 Advice 类型有 before、after 和 around,分别表示在目标方法执行之前、执行后和完全替代目标方法执行的代码。 除了在方法中注入代码,也可能会对代码做其他修改,比如在一个class中增加字段或者接口。
  • Joint point(连接点): 程序中可能作为代码注入目标的特定的点,例如一个方法调用或者方法入口。
  • Pointcut(切入点): 告诉代码注入工具,在何处注入一段特定代码的表达式。例如,在哪些 joint points 应用一个特定的 Advice。切入点可以选择唯一一个,比如执行某一个方法,也可以有多个选择,比如,标记了一个定义成@DebguTrace 的自定义注解的所有方法。
  • Aspect(切面): Pointcut 和 Advice 的组合看做切面。例如,我们在应用中通过定义一个 pointcut 和给定恰当的advice,添加一个日志切面。
  • Weaving(织入): 注入代码(advices)到目标位置(joint points)的过程。

下面这张图简要总结了一下上述这些概念。

传统编程:逐个插入验证用户模块

AOP方案:关注点聚焦

1.3、为什么要用AspectJ?
  • 非侵入式监控: 支持编译期和加载时代码注入,可以在不修监控目标的情况下监控其运行,截获某类方法,甚至可以修改其参数和运行轨迹!
  • 易于使用: 它就是Java,只要会Java就可以用它。
  • 功能强大,可拓展性高: 它就是一个编译器+一个库,可以让开发者最大限度的发挥,实现形形色色的AOP程序!

2、下载AspectJ相关资源与build.gradle配置

2.1、下载地址

下载aspectj的地址http://www.eclipse.org/aspectj/dow…\

2.2、解压aspectj jar包得到aspectjrt.jar
2.3、build.gradle配置

参考build.gradle aspectJ 写法 fernandocejas.com/2014/08/03/…
根目录中build.gradle配置:


buildscript {

repositories {
mavenCentral()
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.0.0'
classpath 'org.aspectj:aspectjtools:1.8.13'
classpath 'org.aspectj:aspectjweaver:1.8.13'

// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}

allprojects {
repositories {
google()
jcenter()
}
}

task clean(type: Delete) {
delete rootProject.buildDir
}

modules中build.gradle配置:


apply plugin: 'com.android.application'
import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main


android {
compileSdkVersion 26
defaultConfig {
applicationId "com.haocai.aopdemo"
minSdkVersion 15
targetSdkVersion 26
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}

final def log = project.logger
final def variants = project.android.applicationVariants

variants.all { variant ->
if (!variant.buildType.isDebuggable()) {
log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
return;
}

JavaCompile javaCompile = variant.javaCompile
javaCompile.doLast {
String[] args = ["-showWeaveInfo",
"-1.8",
"-inpath", javaCompile.destinationDir.toString(),
"-aspectpath", javaCompile.classpath.asPath,
"-d", javaCompile.destinationDir.toString(),
"-classpath", javaCompile.classpath.asPath,
"-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
log.debug "ajc args: " + Arrays.toString(args)

MessageHandler handler = new MessageHandler(true);
new Main().run(args, handler);
for (IMessage message : handler.getMessages(null, true)) {
switch (message.getKind()) {
case IMessage.ABORT:
case IMessage.ERROR:
case IMessage.FAIL:
log.error message.message, message.thrown
break;
case IMessage.WARNING:
log.warn message.message, message.thrown
break;
case IMessage.INFO:
log.info message.message, message.thrown
break;
case IMessage.DEBUG:
log.debug message.message, message.thrown
break;
}
}
}
}

dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation 'com.android.support:appcompat-v7:26.1.0'
implementation 'com.android.support.constraint:constraint-layout:1.0.2'
testImplementation 'junit:junit:4.12'
compile files('libs/aspectjrt.jar')
androidTestImplementation 'com.android.support.test:runner:1.0.1'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1'
// compile 'org.aspectj:aspectjrt:1.8.+'
}
注意:

dependencies 不要忘记添加 compile files('libs/aspectjrt.jar') ,aspectjrt.jar就是上一步解压得到的文件,放到libs文件夹下

3、示例程序

创建注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface BehaviorTrace {
String value();
int type();
}
Aspect 类

/**
* Created by Xionghu on 2018/1/23.
* Desc: 切面
* 你想要切下来的部分(代码逻辑功能重复模块)
*/
@Aspect
public class BehaviorAspect {
private static final String TAG = "MainAspect";
SimpleDateFormat simpleDateFormat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
/**
* 根据切点 切成什么样子
*
*/
@Pointcut("execution(@com.haocai.aopdemo.BehaviorTrace * *(..))")
public void annoBehavior() {

}
/**
* 切成什么样子之后,怎么去处理
*
*/

@Around("annoBehavior()")
public Object dealPoint(ProceedingJoinPoint point) throws Throwable{
//方法执行前
MethodSignature methodSignature = (MethodSignature)point.getSignature();
BehaviorTrace behaviorTrace = methodSignature.getMethod().getAnnotation(BehaviorTrace.class);
String contentType = behaviorTrace.value();
int type = behaviorTrace.type();
Log.i(TAG,contentType+"使用时间: "+simpleDateFormat.format(new Date()));
long beagin=System.currentTimeMillis();
//方法执行时
Object object = null;
try{
object = point.proceed();
}catch (Exception e){
e.printStackTrace();
}

//方法执行完成
Log.i(TAG,"消耗时间:"+(System.currentTimeMillis()-beagin)+"ms");
return object;
}
}
调用主程序


package com.haocai.aopdemo;

import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.SystemClock;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;

import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
public class MainActivity extends AppCompatActivity {
private static final String TAG = "Main";
SimpleDateFormat simpleDateFormat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);





}
/**
* 摇一摇的模块
*
* @param view
*/
@BehaviorTrace(value = "摇一摇",type = 1)
public void mShake(View view)
{
//摇一摇的代码逻辑
{
SystemClock.sleep(3000);
Log.i(TAG," 摇到一个红包");

}
}
/**
* 语音的模块
*
* @param view
*/
@BehaviorTrace(value = "语音:",type = 1)
public void mAudio(View view)
{
//语音代码逻辑
{
SystemClock.sleep(3000);
Log.i(TAG,"发语音:我要到一个红包啦");
}
}
/**
* 打字模块
*
* @param view
*/
@BehaviorTrace(value = "打字:",type = 1)
public void mText(View view)
{
//打字模块逻辑
{
SystemClock.sleep(3000);
Log.i(TAG,"打字逻辑,我摇到了一个大红包");

}

}


// /**
// * 摇一摇的模块
// *
// * @param view
// */
// @BehaviorTrace(value = "摇一摇",type = 1)
// public void mShake(View view)
// {
// SystemClock.sleep(3000);
// Log.i(TAG," 摇到一个嫩模: 约不约");
// }
//
// /**
// * 摇一摇的模块
// *
// * @param view
// */
// public void mShake(View view)
// {
//
// long beagin=System.currentTimeMillis();
// Log.i(TAG,"摇一摇: 使用时间: "+simpleDateFormat.format(new Date()));
// //摇一摇的代码逻辑
// {
// SystemClock.sleep(3000);
//
// Log.i(TAG," 摇到一个红包");
//
// }
// //事件统计逻辑
// Log.i(TAG,"消耗时间: "+(System.currentTimeMillis()-beagin)+"ms");
//
// }

// /**
// * 语音的模块
// *
// * @param view
// */
// public void mAudio(View view)
// {
// long beagin=System.currentTimeMillis();
// Log.i(TAG,"语音: 使用时间: "+simpleDateFormat.format(new Date()));
// //语音代码逻辑
// {
// SystemClock.sleep(3000);
//
// Log.i(TAG,"发语音:我要到一个红包啦");
//
// }
// //事件统计逻辑
// Log.i(TAG,"消耗时间: "+(System.currentTimeMillis()-beagin)+"ms");
// }
//
// /**
// * 打字模块
// *
// * @param view
// */
// public void mText(View view)
// {
// //统计用户行为 的逻辑
// Log.i(TAG,"文字: 使用时间: "+simpleDateFormat.format(new Date()));
// long beagin=System.currentTimeMillis();
//
// //打字模块逻辑
// {
// SystemClock.sleep(3000);
// Log.i(TAG,"打字逻辑,我摇到了一个大红包");
//
// }
// //事件统计逻辑
// Log.i(TAG,"消耗时间: "+(System.currentTimeMillis()-beagin)+"ms");
// }

}
注意:下面注释部分为传统写法
运行结果

01-23 19:39:09.579 13051-13051/com.haocai.aopdemo I/MainAspect: 摇一摇使用时间:   2018-01-23 19:39:09
01-23 19:39:12.589 13051-13051/com.haocai.aopdemo I/Main: 摇到一个红包
01-23 19:39:12.589 13051-13051/com.haocai.aopdemo I/MainAspect: 消耗时间:3001ms
01-23 19:39:12.589 13051-13051/com.haocai.aopdemo I/MainAspect: 语音:使用时间: 2018-01-23 19:39:12
01-23 19:39:15.599 13051-13051/com.haocai.aopdemo I/Main: 发语音:我要到一个红包啦
01-23 19:39:15.599 13051-13051/com.haocai.aopdemo I/MainAspect: 消耗时间:3000ms
01-23 19:39:15.599 13051-13051/com.haocai.aopdemo I/MainAspect: 打字:使用时间: 2018-01-23 19:39:15
01-23 19:39:18.609 13051-13051/com.haocai.aopdemo I/Main: 打字逻辑,我摇到了一个大红包
01-23 19:39:18.609 13051-13051/com.haocai.aopdemo I/MainAspect: 消耗时间:3000ms
收起阅读 »

安卓分页加载器——Paging使用指南

一、简介应用开发过程中分页加载时很普遍的需求,它能节省数据流量,提升应用的性能。 Google为了方便开发者完成分页加载而推出了分页组件—Paging。为几种常见的分页机制提供了统一的解决方案。优势分页数据的内存中缓存。该功能可确保应用在处理分页数据时高效利用...
继续阅读 »

一、简介

应用开发过程中分页加载时很普遍的需求,它能节省数据流量,提升应用的性能。 Google为了方便开发者完成分页加载而推出了分页组件—Paging。为几种常见的分页机制提供了统一的解决方案。

  • 优势
    • 分页数据的内存中缓存。该功能可确保应用在处理分页数据时高效利用系统资源。
    • 内置的请求重复信息删除功能,可确保应用高效利用网络带宽和系统资源。
    • 可配置的RecyclerView适配器,会在用户滚动到已加载数据的末尾时自动请求数据。
    • 对Kotlin协程和Flow以及LiveData和RxJava的一流支持。
    • 内置对错误处理功能的支持,包括刷新和重试功能。
  • 数据来源:Paging支持三种数据架构类型
    • 网络:对网络数据进行分页加载是最常见的需求。API接口通常不太一样,Paging提供了三种不同的方案,应对不同的分页机制。Paging不提供任务错误处理功能,发生错误后可重试网络请求。
    • 数据库:数据库进行分页加载和网络类似,推荐使用Room数据库修改和插入数据。
    • 网络+数据库:通常只采用单一数据源作为解决方案,从网络获取数据,直接缓存进数据库,列表直接从数据库中获取数据。

二、核心

2.1 核心类

Paging的工作原理主要涉及三个类:

  1. PagedListAdapter:RecyclerView.Adapter基类,用于在RecyclerView显示来自PagedList的分页数据。
  2. PagedList:PagedList负责通知DataSource何时获取数据,如加载第一页、最后一页及加载数量等。从DataSource获取的数据将存储在PagedList中。
  3. DataSource:执行具体的数据载入工作,数据载入需要在工作线程中进行

以上三个类的关系及数据加载流程如下图:

20181021221030916.gif

当一条新的item插入到数据库,DataSource会被初始化,LiveData后台线程就会创建一个新的PagedList。这个新的PagedList会被发送到UI线程的PagedListAdapter中,PagedListAdapter使用DiffUtil在对比现在的Item和新建Item的差异。当对比结束,PagedListAdapter通过调用RecycleView.Adapter.notifyItemInserted()将新的item插入到适当的位置

2.2 DataSource

根据分页机制的不同,Paing为我们提供了三种DataSource。

  1. PositionalDataSource

适用于可通过任意位置加载数据,且目标数据源数量固定的情况。

  1. PageKeyedDataSource

适合数据源以“页”的方式进行请求的情况。如获取数据携带pagepageSize时。本文代码使用此DataSource

  1. ItemKeyedDataSource

适用于当目标数据的下一页需要依赖上一页数据中的最后一个对象中的某个字段作为key的情况,如评论数据的接口携带参数sincepageSize

三、使用

3.1 构建自己的DataSource

DataSource控制数据加载,包括初始化加载,加载上页数据,加载下页数据。此处我们以PageKeyedDataSource为例

//泛型参数未Key Value,Key就是每页的标志,此处为Long,Value为数据类型
class ListDataSource : PageKeyedDataSource<Long, Item>() {
//重试加载时的参数
private var lastLoadParam: Pair<LoadParams<Long>, LoadCallback<Long, Item>>? = null

}

其中的关键点在于,每次Key的选定以及loadInitialloadBeforeloadAfter三个函数的重写。PageKeyedDataSource的Key一般依赖与服务端返回的数据。

3.2 构建PagedList

companion object{

private const val TAG = "List"
const val PAGE_SIZE = 5
const val FETCH_DIS = 1

}
val ListData: LiveData<PagedList<Item>> = LivePagedListBuilder(
dataSourceFactory,
Config(
PAGE_SIZE,
FETCH_DIS,
true
)
).build()

其中PAGE_SIZE是每页的数量,FETCH_DIS是距离最后一个数据item还有多少距离就触发加载动作。

此处ListData是LiveData类型,因此可以在Activity中进行监听,当发生数据变化时,则刷新adapter:

ListViewModel.ListData.observe(this) {
adapter.submitList(it)
}

3.3 构建自己的PagedListAdapter

一定要继承PagedListAdapter<Item, RecyclerView.ViewHolder>(``POST_COMPARATOR``)POST_COMPARATOR就是DiffUtil,PagedListAdapter使用DiffUtil在对比现在的Item和新建Item的差异。

typealias ItemClickListener = (Item) -> Unit
typealias onClickListener = () -> Unit

class ListAdapter(
pri
}
}

可以看到基本写法和普通的RecyclerView.Adapter是差不多的,只是多了DiffUtil,使用起来也是一样:

adapter = ListAdapter(
this,
onItemClickListener,
headRetryClickListener,
footRetryClickListener
)
list_rv.adapter = adapter

四、Paging 3.0

Paging3与旧版Paging存在很大区别。Paging2.x运行起来的效果无限滑动还不错,不过代码写起来有点麻烦,功能也不是太完善,比如下拉刷新的方法都没有提供,我们还得自己去调用DataSource#invalidate()方法重置数据来实现。Paging3.0功能更加强大,用起来更简单。

4.1 区别

  • DataSource

Paing2中的DataSource有三种,Paging3中将它们合并到了PagingSource中,实现load()和getRefreshKey(),在Paging3中,所有加载方法参数被一个LoadParams密封类替代,该类中包含了每个加载类型所对应的子类。如果需要区分load()中的加载类型,需要检查传入了LoadParams的哪个子类

  • PagedListAdapter

Adapter不在继承PagedListAdapter,而是由PagingDataAdapter替代,其它不变。

class ArticleAdapter : PagingDataAdapter<Article,ArticleViewHolder>(POST_COMPARATOR){

companion object{

val POST_COMPARATOR = object : DiffUtil.ItemCallback<Article>() {
override fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean =
oldItem == newItem

override fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean =
oldItem.id == newItem.id
}
}

override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
holder.tvName.text = getItem(position)?.title
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder {
return ArticleViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.list_item,parent,false))
}
}

class ArticleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView){
val tvName: TextView = itemView.findViewById(R.id.tvname)
}

4.2 获取数据并设置给Adapter

google提倡我使用三层架构来完成数据到Adapter的设置,如下图

image.png

代码库层

代码库层中的主要 Paging 库组件是 PagingSource。每个 PagingSource 对象都定义了数据源,以及如何从该数据源检索数据。PagingSource 对象可以从任何单个数据源(包括网络来源和本地数据库)加载数据。可使用的另一个 Paging 库组件是 RemoteMediatorRemoteMediator 对象会处理来自分层数据源(例如具有本地数据库缓存的网络数据源)的分页。

ViewModel 层

Pager 组件提供了一个公共 API,基于 PagingSource 对象和 PagingConfig 配置对象来构造在响应式流中公开的 PagingData 实例。将 ViewModel 层连接到界面的组件是 PagingData。 PagingData 对象是用于存放分页数据快照的容器。它会查询 PagingSource 对象并存储结果。

界面层

界面层中的主要 Paging 库组件是 PagingDataAdapter

收起阅读 »

安卓-Glidel图片加载框架学习笔记

引用地址: muyangmin.github.io/glide-docs-… 以glide Version = '4.12.0'为例 1.Gradle配置 此处配置在子模块里(要添加到app主模块也可以),非app主模块里 //glide图片加载框架 impl...
继续阅读 »

引用地址:


muyangmin.github.io/glide-docs-…


以glide Version = '4.12.0'为例


1.Gradle配置


此处配置在子模块里(要添加到app主模块也可以),非app主模块里


//glide图片加载框架
implementation "com.github.bumptech.glide:annotations:${rootProject.glideVersion}"
api "com.github.bumptech.glide:glide:${rootProject.glideVersion}"
annotationProcessor "com.github.bumptech.glide:compiler:${rootProject.glideVersion}"

app模块只需要添加下面一句,然后app主模块引用上面的子模块


implementation project(":CommonModule")
annotationProcessor "com.github.bumptech.glide:compiler:${rootProject.glideVersion}"

2.添加权限声明


添加到子模块AndroidManifest.xml里即可(非app主模块)


<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

3.如果使用到Proguard


如果你有使用到 proguard,那么请把以下代码添加到你的 proguard.cfg 文件中:


-keep public class * implements com.bumptech.glide.module.GlideModule
-keep public class * extends com.bumptech.glide.module.AppGlideModule
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
**[] $VALUES;
public *;
}

4.@GlideModule 定义AppGlideModule子类和LibraryGlideModule子类


定义一个AppGlideModule子类,定义在app主模块里,而且只能定义在app主模块里。


package com.example.myapp;

import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.module.AppGlideModule;

@GlideModule
public final class MyAppGlideModule extends AppGlideModule {}

定义LibraryGlideModule,定义在非app子模块里。


import android.content.Context;
import android.util.Log;

import androidx.annotation.NonNull;

import com.bumptech.glide.Glide;
import com.bumptech.glide.Registry;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.module.LibraryGlideModule;

@GlideModule
public class MyLibraryGlideModule extends LibraryGlideModule {
@Override
public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
super.registerComponents(context, glide, registry);
Log.d("MyLibraryGlideModule","MyLibraryGlideModule");
}
}

定义完成后,不要忘记了 Build ->Make Project,然后会生成会在app\build\generated\ap_generated_sources\debug\out的目录下生成相应的文件,如GlideApp.java,LibraryGlideModule也会自动在生成相应的文件。


5.使用 Generated API


Generated API 默认名为 GlideApp ,与 Application 模块中 AppGlideModule的子类包名相同。在 Application 模块中将 Glide.with() 替换为 GlideApp.with(),即可使用该 API 去完成加载工作:


GlideApp.with(fragment)
.load(myUrl)
.placeholder(R.drawable.placeholder)
.fitCenter()
.into(imageView);

占位符:.placeholder(placeholder) 加载中的占位符


加载失败占位符:.error(android.R.drawable.stat_notify_error)


RequestOptions在多个请求之间共享配置


RequestOptions sharedOptions = 
new RequestOptions()
.placeholder(placeholder)
.fitCenter();

Glide.with(fragment)
.load(myUrl)
.apply(sharedOptions)
.into(imageView1);

Glide.with(fragment)
.load(myUrl)
.apply(sharedOptions)
.into(imageView2);

6.在 ListView 和 RecyclerView 中的使用


在 ListView 或 RecyclerView 中加载图片的代码和在单独的 View 中加载完全一样。Glide 已经自动处理了 View 的复用和请求的取消:


View 调用 clear()into(View),表明在此之前的加载操作会被取消,并且在方法调用完成后,Glide 不会改变 view 的内容。如果你忘记调用 clear(),而又没有开启新的加载操作,那么就会出现这种情况,你已经为一个 view 设置好了一个 Drawable,但该 view 在之前的位置上使用 Glide 进行过加载图片的操作,Glide 加载完毕后可能会将这个 view 改回成原来的内容。


这里的代码以 RecyclerView 的使用为例,但规则同样适用于 ListView。


正确用法1


@Override
public void onBindViewHolder(ViewHolder holder, int position) {
String url = urls.get(position);
Glide.with(fragment)
.load(url)
.into(holder.imageView);
}

正确用法2


@Override
public void onBindViewHolder(ViewHolder holder, int position) {
if (isImagePosition(position)) {
String url = urls.get(position);
Glide.with(fragment)
.load(url)
.into(holder.imageView);
} else {
Glide.with(fragment).clear(holder.imageView);
holder.imageView.setImageDrawable(specialDrawable);
}
}

7.非 View 目标


除了将 BitmapDrawable 加载到 View 之外,你也可以开始异步加载到你的自定义 Target 中:


Glide.with(context
.load(url)
.into(new CustomTarget<Drawable>() {
@Override
public void onResourceReady(Drawable resource, Transition<Drawable> transition) {
// Do something with the Drawable here.
}

@Override
public void onLoadCleared(@Nullable Drawable placeholder) {
// Remove the Drawable provided in onResourceReady from any Views and ensure
// no references to it remain.
}
});

8.后台线程


后台线程下载,


FutureTarget<Bitmap> futureTarget =
Glide.with(context)
.asBitmap()
.load(url)
.submit(width, height);

Bitmap bitmap = futureTarget.get();

// Do something with the Bitmap and then when you're done with it:
Glide.with(context).clear(futureTarget);

后台同步实现图片下载


 //不能直接在主线程里调用,会直接ANR
FutureTarget<File> futureTarget = Glide.with(GlideImageLoadFragment.this)
.asFile()
.load("https://dss3.bdstatic.com/iPoZeXSm1A5BphGlnYG/skin/822.jpg?2")
.addListener(new RequestListener<File>() {
@Override
public boolean onLoadFailed(@Nullable GlideException e,
Object model, Target<File> target,
boolean isFirstResource) {
return false;
}

@Override
public boolean onResourceReady(File resource, Object model, Target<File> target, DataSource dataSource, boolean isFirstResource) {
return false;
}
}).submit();

//下载到的文件没有扩展名。
File file = futureTarget.get();

异常下载图片并回调 .asFile()


GlideApp.with(this)
.asFile()
.load("https://img.soogif.com/rSlMSm7msQagXhSSgIQ0LtqTusCK712l.gif")
.into(new CustomTarget<File>() {
@Override
public void onResourceReady(@NonNull File resource, @Nullable Transition<? super File> transition) {
Log.d("", "");
}

@Override
public void onLoadCleared(@Nullable Drawable placeholder) {
Log.d("", "");
}
});

8.Glide扩展 GlideExtension


@GlideExtension 注解用于标识一个扩展 Glide API 的类。任何扩展 Glide API 的类都必须使用这个注解来标记,否则其中被注解的方法就会被忽略。


@GlideExtension 注解的类应以工具类的思维编写。这种类应该有一个私有的、空的构造方法,应为 final 类型,并且仅包含静态方法。被注解的类可以含有静态变量,可以引用其他的类或对象。


在 Application 模块中可以根据需求实现任意多个被 @GlideExtension 注解的类,在 Library 模块中同样如此。当 AppGlideModule 被发现时,所有有效的 Glide 扩展类 会被合并,所有的选项在 API 中均可以被调用。合并冲突会导致 Glide 的 Annotation Processor 抛出编译错误。


@GlideExtention 注解的类有两种扩展方式:



  1. GlideOption - 为 RequestOptions 添加一个自定义的选项。

  2. GlideType - 添加对新的资源类型的支持(GIF,SVG 等等)。


@GlideExtension


@GlideExtension只能在app模块里使用。


//@GlideExtension 注解到类上面,只能在app模块里使用。
@GlideExtension
public class MyAppExtension {
// Size of mini thumb in pixels.
private static final int MINI_THUMB_SIZE = 100;

private MyAppExtension() { } // utility class

//@GlideOption注解到方法里面。标记的方法应该为静态方法
@NonNull
@GlideOption
public static BaseRequestOptions<?> miniThumb(BaseRequestOptions<?> options) {
return options
.fitCenter()
.override(MINI_THUMB_SIZE);
}

// 你可以为方法任意添加参数,但要保证第一个参数为 RequestOptions。 标记的方法应该为静态方法
@GlideOption
public static BaseRequestOptions<?> miniThumb(BaseRequestOptions<?> options, int size) {
return options
.fitCenter()
.override(size);
}
}

添加完成后 执行 Build ->Make Project,可以看到app\build\generated\ap_generated_sources\debug\out\my\android\architecture\samples\glide\GlideOptions.java


里生成了下面的方法


@SuppressWarnings("unchecked")
@CheckResult
@NonNull
public GlideOptions miniThumb() {
return (GlideOptions) MyAppExtension.miniThumb(this);
}

/**
* @see MyAppExtension#miniThumb(BaseRequestOptions, int)
*/
@SuppressWarnings("unchecked")
@CheckResult
@NonNull
public GlideOptions miniThumb(int size) {
return (GlideOptions) MyAppExtension.miniThumb(this, size);
}

调用


GlideApp.with(fragment)
.load(url)
.miniThumb(thumbnailSize)
.into(imageView);

GlideType


@GlideType 注解的静态方法用于扩展 RequestManager 。被 @GlideType 注解的方法允许你添加对新的资源类型的支持,包括指定默认选项。


例如,为添加对 GIF 的支持,你可以添加一个被 @GlideType 注解的方法:


@GlideExtension
public class MyAppExtension {
private static final RequestOptions DECODE_TYPE_GIF = decodeTypeOf(GifDrawable.class).lock();

@NonNull
@GlideType(GifDrawable.class)
public static RequestBuilder<GifDrwable> asGif(RequestBuilder<GifDrawable> requestBuilder) {
return requestBuilder
.transition(new DrawableTransitionOptions())
.apply(DECODE_TYPE_GIF);
}
}

这样会生成一个包含对应方法的 RequestManager


public class GlideRequests extends RequesetManager {

public GlideRequest<GifDrawable> asGif() {
return (GlideRequest<GifDrawable> MyAppExtension.asGif(this.as(GifDrawable.class));
}

...
}

9.占位符


Glide允许用户指定三种不同类型的占位符,分别在三种不同场景使用:


placeholder
error
fallback


占位符(Placeholder)


占位符是当请求正在执行时被展示的 Drawable 。当请求成功完成时,占位符会被请求到的资源替换。如果被请求的资源是从内存中加载出来的,那么占位符可能根本不会被显示。如果请求失败并且没有设置 error Drawable ,则占位符将被持续展示。类似地,如果请求的url/model为 null ,并且 error Drawablefallback 都没有设置,那么占位符也会继续显示。


错误符(Error)


error Drawable 在请求永久性失败时展示。error Drawable 同样也在请求的url/model为 null ,且并没有设置 fallback Drawable 时展示。


后备回调符(Fallback)


fallback Drawable 在请求的url/model为 null 时展示。设计 fallback Drawable 的主要目的是允许用户指示 null 是否为可接受的正常情况。例如,一个 null 的个人资料 url 可能暗示这个用户没有设置头像,因此应该使用默认头像。然而,null 也可能表明这个元数据根本就是不合法的,或者取不到。 默认情况下Glide将 null 作为错误处理,所以可以接受 null 的应用应当显式地设置一个 fallback Drawable


占位符是异步加载的吗?

No。占位符是在主线程从Android Resources加载的。我们通常希望占位符比较小且容易被系统资源缓存机制缓存起来。


变换是否会被应用到占位符上?

No。Transformation仅被应用于被请求的资源,而不会对任何占位符使用。


在应用中包含必须在运行时做变换才能使用的图片资源是很不划算的。相反,在应用中包含一个确切符合尺寸和形状要求的资源版本几乎总是一个更好的办法。假如你正在加载圆形图片,你可能希望在你的应用中包含圆形的占位符。另外你也可以考虑自定义一个View来剪裁(clip)你的占位符,而达到你想要的变换效果。


在多个不同的View上使用相同的Drawable可行么?

通常可以,但不是绝对的。任何无状态(non-stateful)的 Drawable(例如 BitmapDrawable )通常都是ok的。但是有状态的 Drawable 不一样,在同一时间多个 View 上展示它们通常不是很安全,因为多个View会立刻修改(mutate) Drawable 。对于有状态的 Drawable ,建议传入一个资源ID,或者使用 newDrawable() 来给每个请求传入一个新的拷贝。


10.选项


1.请求选项


Glide中的大部分设置项都可以直接应用在 Glide.with() 返回的 RequestBuilder 对象上。


可用的选项包括(但不限于):



  • 占位符(Placeholders)

  • 转换(Transformations)

  • 缓存策略(Caching Strategies)

  • 组件特有的设置项,例如编码质量,或Bitmap的解码配置等。


例如,要应用一个 CenterCrop 转换,你可以使用以下代码:


Glide.with(fragment)
.load(url)
.centerCrop()
.into(imageView);

RequestOptions对象 apply(@NonNull BaseRequestOptions<?> options)


如果你想让你的应用的不同部分之间共享相同的加载选项,你也可以初始化一个新的 RequestOptions 对象,并在每次加载时通过 apply() 方法传入这个对象:


RequestOptions cropOptions = new RequestOptions().centerCrop(context);
...
Glide.with(fragment)
.load(url)
.apply(cropOptions)
.into(imageView);

apply() 方法可以被调用多次,因此 RequestOption 可以被组合使用。如果 RequestOptions 对象之间存在相互冲突的设置,那么只有最后一个被应用的 RequestOptions 会生效。


过渡选项 transition


不同于RequestOptionsTransitionOptions是特定资源类型独有的,你能使用的变换取决于你让Glide加载哪种类型的资源。


这样的结果是,假如你请求加载一个 Bitmap ,你需要使用 BitmapTransitionOptions ,而不是 DrawableTransitionOptions 。同样,当你请求加载 Bitmap时,你只需要做简单的淡入,而不需要做复杂的交叉淡入。


RequestBuilder


使用 RequestBuilder 可以指定:



  • 你想加载的资源类型(Bitmap, Drawable, 或其他)

  • 你要加载的资源地址(url/model)

  • 你想最终加载到的View

  • 任何你想应用的(一个或多个)RequestOption 对象

  • 任何你想应用的(一个或多个)TransitionOption 对象

  • 任何你想加载的缩略图 thumbnail()


选择资源类型


作者:缘焕
链接:https://juejin.cn/post/7006871344877060110
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Swift 5.5 新特性

iOS
Swift 5.5 内置于 Xcode 13,虽然版本号只增加了 0.1,看似是一个小版本升级,但却带来了非常多的新内容,其中最大的更新是引入了全新的并发编程方式。条件编译支持表达式SwiftUI 在跨平台时会使用到条件 Modifier,之前的解决方案是自己...
继续阅读 »

Swift 5.5 内置于 Xcode 13,虽然版本号只增加了 0.1,看似是一个小版本升级,但却带来了非常多的新内容,其中最大的更新是引入了全新的并发编程方式。

条件编译支持表达式

SwiftUI 在跨平台时会使用到条件 Modifier,之前的解决方案是自己写一套判断体系, Swift 5.5 以后,原生支持条件编译表达式,跨平台更加方便。

struct ContentView: View {
var body: some View {
Text("SwiftUI")
#if os(iOS)
.foregroundColor(.blue)
#elseif os(macOS)
.foregroundColor(.green)
#else
.foregroundColor(.pink)
#endif
}
}
复制代码

CGFloat与Double支持隐式转换

let number1: CGFloat = 12.34
let number2: Double = 56.78
let result = number1 + number2 // result为Double类型
复制代码

下面的代码在 Swift 5.5 之前会报错,因为scale为 Double 类型,而 SwiftUI 中需要绑定 CGFloat 类型。

struct ContentView: View {
@State private var scale = 1.0 // Double类型

var body: some View {
VStack {
Image(systemName: "heart")
.scaleEffect(scale) // 隐式转换为CGFloat

Slider(value: $scale, in: 0 ... 1)
}
}
}
复制代码

在通用上下文中扩展静态成员查找(static member lookup)

这个新特性使得 SwiftUI 中的部分语法更加简洁好用。

struct ContentView: View {
@Binding var name: String

var body: some View {
HStack {
Text(name)

TextField("", text: $name)
// .textFieldStyle(RoundedBorderTextFieldStyle()) // 以前写法
.textFieldStyle(.roundedBorder) // 新写法,更简洁
}
}
}
复制代码

局部变量支持lazy

func lazyInLocalContext() {
print("lazy之前")
lazy var swift = "Hello Swift 5.5"
print("lazy之后")

print(swift)
}

// 调用
lazyInLocalContext()

/* 输出
lazy之前
lazy之后
Hello Swift 5.5
*/
复制代码

函数和闭包参数支持属性包装

  • Swift 5.1 中引入了属性包装。
  • Swift 5.4 将属性包装支持到局部变量。
  • Swift 5.5 将属性包装支持到函数和闭包参数。
@propertyWrapper struct Trimmed {
private var value: String = ""

var wrappedValue: String {
get { value }
set { value = newValue.trimmingCharacters(in: .whitespacesAndNewlines) }
}

init(wrappedValue initialValue: String) {
wrappedValue = initialValue
}
}

struct Post {
func trimed(@Trimmed content: String) { // 函数参数支持PropertyWrapper
print(content)
}
}

let post = Post()
post.trimed(content: " Swift 5.5 Property Wrappers ")
复制代码

带有关联值的枚举支持Codable

有了该功能之后,枚举就可以像结构体、类一样用来作为数据模型了。

  • 枚举到 JSON。
// 定义带有关联值的枚举
enum Score: Codable {
case number(score: Double)
case letter(score: String)
}

// 创建对象
let scores: [Score] = [.number(score: 98.5), .letter(score: "优")]

// 转JSON
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
do {
let result = try encoder.encode(scores)
let json = String(decoding: result, as: UTF8.self)
print(json)
} catch {
print(error.localizedDescription)
}
复制代码
  • JSON 到枚举。
enum Score: Codable {
case number(score: Double)
case letter(score: String)
}

// JSON
let json = """
[
{
"number" : {
"score" : 98.5
}
},
{
"letter" : {
"score" : "优"
}
}
]
"""

// 转枚举
let decoder = JSONDecoder()
do {
let scores = try decoder.decode([Score].self, from: json.data(using: .utf8)!)
for score in scores {
switch score {
case let .number(value):
print(value)
case let .letter(value):
print(value)
}
}
} catch {
print(error.localizedDescription)
}
复制代码

并发编程

内容较多且尚不稳定,后面会单独出《Swift 5.5 Concurrency》

收起阅读 »

iOS - Core Graphics快速入门——从一行代码说起

iOS
Core Graphics入门想必每个第一次接触Core Graphics的开发者都被无数的API、混乱的代码逻辑折腾得头疼不已,甚至望而却步。即使是绘制一个简单的矩形也看上去非常繁琐。本文换一个角度,整理一下有关Core Graphics的知识,也算作是这段...
继续阅读 »

Core Graphics入门

想必每个第一次接触Core Graphics的开发者都被无数的API、混乱的代码逻辑折腾得头疼不已,甚至望而却步。即使是绘制一个简单的矩形也看上去非常繁琐。本文换一个角度,整理一下有关Core Graphics的知识,也算作是这段时间学习的总结。

Core Graphics和UIKit的区别

首先从概念上了解一下:


根据苹果的描述,UIKit是我们最容易也是最常接触到的框架。绝大多数图形界面都由UIKit完成。但是UIKit依赖于Core Graphics框架,也是基于Core Graphics框架实现的。如果想要完成某些更底层的功能或者追求极致的性能,那么依然推荐使用Core Graphics完成。

Core Graphics和UIKit在实际使用中也存在以下这些差异:

  1. Core Graphics其实是一套基于C的API框架,使用了Quartz作为绘图引擎。这也就意味着Core Graphics不是面向对象的。
  2. Core Graphics需要一个图形上下文(Context)。所谓的图形上下文(Context),说白了就是一张画布。这一点非常容易理解,Core Graphics提供了一系列绘图API,自然需要指定在哪里画图。因此很多API都需要一个上下文(Context)参数。
  3. Core Graphics的图形上下文(Context)是堆栈式的。只能在栈顶的上下文(画布)上画图。
  4. Core Graphics中有一些API,名称不同却有着相似的功能,新手只需要掌握一种,并能够看懂其他的即可。

从一行代码说起

下面这行代码应该是很多人最早也是最常写的代码。它简单到我们根本不用思考它的本质。

[self.view addSubview:myButton];

细想一下,UIButton也是继承自UIView。这段代码表示,UIKit绘图的基本思想是通过UIView的叠加实现最终的整体效果。它主要涉及三个内容:画布、被添加的控件和添加方法。这里的self.view其实就充当了一张画布。通过添加不同的UI控件达到最终效果。我们顺着这个线索整理一下Core Graphics的编程思路。

Core Graphics的基本使用

为了使用Core Graphics来绘图,最简单的方法就是自定义一个类继承自UIView,并重写子类的drawRect方法。在这个方法中绘制图形。
Core Graphics必须一个画布,才能把东西画在这个画布上。在drawRect方法方法中,我们可以直接获取当前栈顶的上下文(Context)。下面的代码演示了具体操作步骤:

- (void)drawRect:(CGRect)rect {
CGContextRef ctx = UIGraphicsGetCurrentContext();
}

现在我们已经完成了Core Graphics绘图的三分之一——创建一个画布。
接下来需要考虑被画上去的东西。这在UIKit中往往是一个UI控件,如Button、Label等。而在Core Graphics中通常表现为一些基本图形:三角形、矩形、圆形、以及这些图形的边框等。

这通常会涉及到非常多的API,但是如果总结一下不难发现,任何一个要绘制的东西(为了避免混淆就不称为对象了)一定有一个边框,或者称为边界。在一个几英寸的屏幕上画出无界的图形是不可能的。所以一旦确定了一个边框,我们就可以设置边框的各种绘图属性、边框内部区域的绘图属性、绘制边框还是内部区域等。

这就引出了Core Graphics中的路径(Path)的概念。在前一段代码的基础上演示路径的使用:

- (void)drawSomething{
CGContextRef context = UIGraphicsGetCurrentContext();//获取上下文
CGMutablePathRef path = CGPathCreateMutable();//创建路径
CGPathMoveToPoint(path, nil, 20, 50);//移动到指定位置(设置路径起点)
CGPathAddLineToPoint(path, nil, 20, 100);//绘制直线(从起始位置开始)
CGContextAddPath(context, path);//把路径添加到上下文(画布)中
}

这里通过CGPathCreateMutable方法创建了一个路径。路径的外在表现就像一条折线。为了绘制一条路径,需要用CGPathMoveToPoint函数指定路径的起点。CGPathAddLineToPoint函数表示在路径的最后结束点和新的点之间再加一条直线。相当于拓展了原来路径。通过这样的简单的点的累加,可以绘制非常复杂的折线。

但这存在两个问题:

  1. 绘制矩形等规则多边形的过程过于繁琐
  2. 无法绘制曲线。

这些问题Core Graphics早已提供了解决办法。注意到之前我们添加了一个非常普通的自定义路径。Core Graphics中还提供了很多预先设置好的路径。不妨在drawRect方法中输入“cgcontextadd”试试看。

技术分享

这些方法由Core Graphics提供,可以用来绘制圆形、椭圆、矩形、二次曲线等路径。创建完路径后还要记得调用CGContextAddPath方法将路径添加到上下文中。路径只是我们画的一条线而已,不把他画到上,他就没有什么卵用。

添加好路径后,就要开始画图了。正如前面提出的问题所说,画图的时候需要考虑画不画边框、画不画边框内部的区域,边框的粗细、颜色、内部区域颜色等问题。Core Graphics提供了另一个方法集合”CGContextSet”来进行这些设置。常见的设置内容如下:

- (void)drawSomething{
CGContextRef context = UIGraphicsGetCurrentContext();//获取上下文
CGMutablePathRef path = CGPathCreateMutable();//创建路径
CGPathMoveToPoint(path, nil, 20, 50);//移动到指定位置(设置路径起点)
CGPathAddLineToPoint(path, nil, 20, 100);//绘制直线(从起始位置开始)
CGContextAddPath(context, path);//把路径添加到上下文(画布)中

//设置图形上下文状态属性
CGContextSetRGBStrokeColor(context, 1.0, 0, 0, 1);//设置笔触颜色
CGContextSetRGBFillColor(context, 0, 1.0, 0, 1);//设置填充色
CGContextSetLineWidth(context, 2.0);//设置线条宽度
CGContextSetLineCap(context, kCGLineCapRound);//设置顶点样式
CGContextSetLineJoin(context, kCGLineJoinRound);//设置连接点样式
CGFloat lengths[2] = { 18, 9 };
CGContextSetLineDash(context, 0, lengths, 2);
CGContextSetShadowWithColor(context, CGSizeMake(2, 2), 0, [UIColor blackColor].CGColor);
CGContextDrawPath(context, kCGPathFillStroke);//最后一个参数是填充类型
}

设置属性的前三行就不再解释了,看一些注释足矣。顶点指的是路径的起始点和结束点,连接点指的是路径中的转折点(折现才有)。SetLineDash用于绘制虚线,具体用法参见——《IOS中使用Quartz 2D绘制虚线》。SetShadow方法用于绘制阴影,第二个参数是一个CGSize对象,用于表示阴影偏移量,第三个参数表示模糊度,数值越大,阴影越模糊,第一个参数是一个CGColor,表示阴影颜色,需要由UIColor转换得到。

至此,我们完成了Core Graphics绘图的第二步,也是最复杂的一部分:设置绘图内容。这相当于此前那行代码的中的UI控件。

设置好了绘图的属性之后,就可以调用CGContextDrawPath方法绘图了。第一个参数表示要在哪一个上下文中绘图,第二个参数表示填充类型。在填充类型中可以选择只绘制边框、只填充、同时绘制边框和填充内部区域、奇偶规则填充等。


从方法名不难看出,但是也需要注意的是,这些设置都是对上下文(context)生效的。这样会导致,所有的边框颜色、粗细都一样。一个简单的解决办法就是在需要修改设置之前调用一次CGContextDrawPath方法绘图。再修改设置,修改设置之后再次绘制。

图画完了,还得做一下清理工作。CGPathCreateMutable方法返回的路径是一个Core Fundation Object。而这并不在ARC的管理范围之内。所以需要手动释放对象。

- (void)drawRect:(CGRect)rect {
CGContextRef context = UIGraphicsGetCurrentContext();//获取上下文
CGMutablePathRef path = CGPathCreateMutable();//创建路径
/*
绘图
*/

CGPathRelease(path);
}

这样就完成了Core Graphics绘图的第三部分——开始绘图。
再总结一下使用Core Graphics绘图的步骤:

  1. 获取上下文(画布)
  2. 创建路径(自定义或者调用系统的API)并添加到上下文中。
  3. 进行绘图内容的设置(画笔颜色、粗细、填充区域颜色、阴影、连接点形状等)
  4. 开始绘图(CGContextDrawPath)
  5. 释放路径(CGPathRelease)
收起阅读 »

iOS - 绘图框架CoreGraphics分析

iOS
由于CoreGraphics框架有太多的API,对于初次接触或者对该框架不是十分了解的人,在绘图时,对API的选择会感到有些迷茫,甚至会觉得iOS的图形绘制有些繁琐。因此,本文主要介绍一下iOS的绘图方法和分析一下CoreGraphics框架的绘图原理。一、绘...
继续阅读 »

由于CoreGraphics框架有太多的API,对于初次接触或者对该框架不是十分了解的人,在绘图时,对API的选择会感到有些迷茫,甚至会觉得iOS的图形绘制有些繁琐。因此,本文主要介绍一下iOS的绘图方法和分析一下CoreGraphics框架的绘图原理。

一、绘图系统简介

iOS的绘图框架有多种,我们平常最常用的就是UIKit,其底层是依赖CoreGraphics实现的,而且绝大多数的图形界面也都是由UIKit完成,并且UIImage、NSString、UIBezierPath、UIColor等都知道如何绘制自己,也提供了一些方法来满足我们常用的绘图需求。除了UIKit,还有CoreGraphics、Core Animation,Core Image,OpenGL ES等多种框架,来满足不同的绘图要求。各个框架的大概介绍如下:

  • UIKit:最常用的视图框架,封装度最高,都是OC对象

  • CoreGraphics:主要绘图系统,常用于绘制自定义视图,纯C的API,使用Quartz2D做引擎

  • CoreAnimation:提供强大的2D和3D动画效果

  • CoreImage:给图片提供各种滤镜处理,比如高斯模糊、锐化等

  • OpenGL-ES:主要用于游戏绘制,但它是一套编程规范,具体由设备制造商实现

绘图系统


二、绘图方式

实际的绘图包括两部分:视图绘制视图布局,它们实现的功能是不同的,在理解这两个概念之前,需要了解一下什么是绘图周期,因为都是在绘图周期中进行绘制的。

绘图周期:

  • iOS在运行循环中会整合所有的绘图请求,并一次将它们绘制出来

  • 不能在子线程中绘制,也不能进行复杂的操作,否则会造成主线程卡顿

1.视图绘制

调用UIView的drawRect:方法进行绘制。如果调用一个视图的setNeedsDisplay方法,那么该视图就被标记为重新绘制,并且会在下一次绘制周期中重新绘制,自动调用drawRect:方法。

2.视图布局

调用UIView的layoutSubviews方法。如果调用一个视图的setNeedsLayout方法,那么该视图就被标记为需要重新布局,UIKit会自动调用layoutSubviews方法及其子视图的layoutSubviews方法。

在绘图时,我们应该尽量多使用布局,少使用绘制,是因为布局使用的是GPU,而绘制使用的是CPU。GPU对于图形处理有优势,而CPU要处理的事情较多,且不擅长处理图形,所以尽量使用GPU来处理图形。

三、绘图状态切换

iOS的绘图有多种对应的状态切换,比如:pop/push、save/restore、context/imageContext和CGPathRef/UIBezierPath等,下面分别进行介绍:

1.pop / push

设置绘图的上下文环境(context)

push:UIGraphicsPushContext(context)把context压入栈中,并把context设置为当前绘图上下文

pop:UIGraphicsPopContext将栈顶的上下文弹出,恢复先前的上下文,但是绘图状态不变

下面绘制的视图是黑色

- (void)drawRect:(CGRect)rect {
[[UIColor redColor] setFill];
UIGraphicsPushContext(UIGraphicsGetCurrentContext());
[[UIColor blackColor] setFill];
UIGraphicsPopContext();
UIRectFill(CGRectMake(90, 340, 100, 100)); // black color
}

2.save / restore

设置绘图的状态(state)

save:CGContextSaveGState 压栈当前的绘图状态,仅仅是绘图状态,不是绘图上下文

restore:恢复刚才保存的绘图状态

下面绘制的视图是红色

- (void)drawRect:(CGRect)rect {
[[UIColor redColor] setFill];
CGContextSaveGState(UIGraphicsGetCurrentContext());
[[UIColor blackColor] setFill];
CGContextRestoreGState(UIGraphicsGetCurrentContext());
UIRectFill(CGRectMake(90, 200, 100, 100)); // red color
}

3.context / imageContext

iOS的绘图必须在一个上下文中绘制,所以在绘图之前要获取一个上下文。如果是绘制图片,就需要获取一个图片的上下文;如果是绘制其它视图,就需要一个非图片上下文。对于上下文的理解,可以认为就是一张画布,然后在上面进行绘图操作。

context:图形上下文,可以通过UIGraphicsGetCurrentContext:获取当前视图的上下文

imageContext:图片上下文,可以通过UIGraphicsBeginImageContextWithOptions:获取一个图片上下文,然后绘制完成后,调用UIGraphicsGetImageFromCurrentImageContext获取绘制的图片,最后要记得关闭图片上下文UIGraphicsEndImageContext。

4.CGPathRef / UIBezierPath

图形的绘制需要绘制一个路径,然后再把路径渲染出来,而CGPathRef就是CoreGraphics框架中的路径绘制类,UIBezierPath是封装CGPathRef的面向OC的类,使用更加方便,但是一些高级特性还是不及CGPathRef。

四、具体绘图方法

由于iOS常用的绘图框架有UIKit和CoreGraphics两个,所以绘图的方法也有多种,下面介绍一下iOS的几种常用的绘图方法。

1.图片类型的上下文

图片上下文的绘制不需要在drawRect:方法中进行,在一个普通的OC方法中就可以绘制

使用UIKit实现

// 获取图片上下文
UIGraphicsBeginImageContextWithOptions(CGSizeMake(100,100), NO, 0);
// 绘图
UIBezierPath* p = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(0,0,100,100)];
[[UIColor blueColor] setFill];
[p fill];
// 从图片上下文中获取绘制的图片
UIImage* im = UIGraphicsGetImageFromCurrentImageContext();
// 关闭图片上下文
UIGraphicsEndImageContext();

使用CoreGraphics实现

// 获取图片上下文
UIGraphicsBeginImageContextWithOptions(CGSizeMake(100,100), NO, 0);
// 绘图
CGContextRef con = UIGraphicsGetCurrentContext();
CGContextAddEllipseInRect(con, CGRectMake(0,0,100,100));
CGContextSetFillColorWithColor(con, [UIColor blueColor].CGColor);
CGContextFillPath(con);
// 从图片上下文中获取绘制的图片
UIImage* im = UIGraphicsGetImageFromCurrentImageContext();
// 关闭图片上下文
UIGraphicsEndImageContext();

2.drawRect:

在UIView子类的drawRect:方法中实现图形重新绘制,绘图步骤如下:

  • 获取上下文

  • 绘制图形

  • 渲染图形

UIKit方法

- (void) drawRect: (CGRect) rect {
UIBezierPath* p = [UIBezierPathbezierPathWithOvalInRect:CGRectMake(0,0,100,100)];
[[UIColor blueColor] setFill];
[p fill];
}

CoreGraphics

- (void) drawRect: (CGRect) rect {
CGContextRef con = UIGraphicsGetCurrentContext();
CGContextAddEllipseInRect(con, CGRectMake(0,0,100,100));
CGContextSetFillColorWithColor(con, [UIColor blueColor].CGColor);
CGContextFillPath(con);
}

3.drawLayer:inContext:

在UIView子类的drawLayer:inContext:方法中也可以实现绘图任务,它是一个图层的代理方法,而为了能够调用该方法,需要给图层的delegate设置代理对象,其中代理对象不能是UIView对象,因为UIView对象已经是它内部根层(隐式层)的代理对象,再将它设置为另一个层的代理对象就会出问题。

一个view被添加到其它view上时,图层的变化如下:

  • 先隐式地把此view的layer的CALayerDelegate设置成此view

  • 调用此view的self.layer的drawInContext方法

  • 由于drawLayer方法的注释:If defined, called by the default implementation of -drawInContext:说明了drawInContext里if([self.delegate responseToSelector:@selector(drawLayer:inContext:)])就执行drawLayer:inContext:方法,这里我们因为实现了drawLayer:inContext:所以会执行

  • [super drawLayer:layer inContext:ctx]会让系统自动调用此view的drawRect:方法,至此self.layer画出来了

  • 在self.layer上再加一个子layer,当调用[layer setNeedsDisplay];时会自动调用此layer的drawInContext方法

  • 如果drawRect不重写,就不会调用其layer的drawInContext方法,也就不会调用drawLayer:inContext方法

调用内部根层的drawLayer:inContext:

//如果drawRect不重写,就不会调用其layer的drawInContext方法,也就不会调用drawLayer:inContext方法
-(void)drawRect:(CGRect)rect{
NSLog(@"2-drawRect:");
NSLog(@"drawRect里的CGContext:%@",UIGraphicsGetCurrentContext());
//得到的当前图形上下文正是drawLayer中传递过来的
[super drawRect:rect];
}
#pragma mark - CALayerDelegate
-(void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx{
NSLog(@"1-drawLayer:inContext:");
NSLog(@"drawLayer里的CGContext:%@",ctx);
// 如果去掉此句就不会执行drawRect!!!!!!!!
[super drawLayer:layer inContext:ctx];
}

调用外部代理对象的drawLayer:inContext:

由于不能把UIView对象设置为CALayerDelegate的代理,所以我们需要创建一个NSObject对象,然后实现drawLayer:inContext:方法,这样就可以在代理对象里绘制所需图形。另外,在设置代理时,不需要遵守CALayerDelegate的代理协议,即这个方法是NSObject的,不需要显式地指定协议。

// MyLayerDelegate.m
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx
{
CGContextAddEllipseInRect(ctx, CGRectMake(100,100,100,100));
CGContextSetFillColorWithColor(ctx, [UIColor blueColor].CGColor);
CGContextFillPath(ctx);
}
// ViewController.m
@interface ViewController () @property (nonatomic, strong) id myLayerDelegate;
@end
@implementation ViewController
- (void)viewDidLoad {
// 设置layer的delegate为NSObject子类对象
_myLayerDelegate = [[MyLayerDelegate alloc] init];
MyView *myView = [[MyView alloc] initWithFrame:self.view.bounds];
[self.view addSubview:myView];
CALayer *layer = [CALayer layer];
layer.backgroundColor = [UIColor magentaColor].CGColor;
layer.bounds = CGRectMake(0, 0, 300, 500);
layer.anchorPoint = CGPointZero;
layer.delegate = _myLayerDelegate;
[layer setNeedsDisplay];
[myView.layer addSublayer:layer];
}

详细实现过程

当UIView需要显示时,它内部的层会准备好一个CGContextRef(图形上下文),然后调用delegate(这里就是UIView)的drawLayer:inContext:方法,并且传入已经准备好的CGContextRef对象。而UIView在drawLayer:inContext:方法中又会调用自己的drawRect:方法。平时在drawRect:中通过UIGraphicsGetCurrentContext()获取的就是由层传入的CGContextRef对象,在drawRect:中完成的所有绘图都会填入层的CGContextRef中,然后被拷贝至屏幕。

iOS绘图框架分析如上,如有不足之处,欢迎指出,共同进步。(本文图片来自互联网,版权归原作者所有)

收起阅读 »

2021年,跨端是否已成趋势?Android 开发还有必要学 Flutter 吗?

由于手机APP的运行受不同操作系统的限制,目前大多数的移动APP应用开发仍然需要针对不同的系统环境进行单独的开发。不过,为了降低开发成本、提高代码复用率,减少开发者对多个平台差异适配的工作量一直是跨平台开发框架追求的目标。 但是目前,很多开发者还不不确定应该选...
继续阅读 »

由于手机APP的运行受不同操作系统的限制,目前大多数的移动APP应用开发仍然需要针对不同的系统环境进行单独的开发。不过,为了降低开发成本、提高代码复用率,减少开发者对多个平台差异适配的工作量一直是跨平台开发框架追求的目标。


但是目前,很多开发者还不不确定应该选择哪种技术来快速且低成本的开发应用程序,不过如果你熟知跨平台的发展历史,那么2021年可供大家选择的跨平台方案主选项只有两个:Flutter或者React Native



在正式进行对比之前,首先需要明确一点,即Flutter和React Native这两个框架都是构建跨平台移动应用程序的优质框架,但有时做出正确的决定取决于业务使用的角度。因此,我们选取了九个重要的参数,用于两者的比较:



  • 由谁提供技术支持?

  • 框架的市场份额占比。

  • Dart Vs JavaScript

  • 技术架构

  • 性能

  • 是否对开发者友好,便利性和社区支持

  • UI组件和定制

  • 代码的可维护性

  • 开发者的工作成本


技术支持:谷歌 VS Facebook


Flutter与React Native两大框架背后都站着科技巨头,分别是谷歌和Facebook,所以从这个角度来看两者未来会在竞争中变得更加完善,毕竟他们背后都自己的利益链。


首先,我们来看一下Flutter,Flutter是2017年由谷歌正式推出,是一个先进的应用程序软件开发工具包(SDK),包括所有的小部件和工具,理论上可以让开发者的开发过程更容易和更简单。广泛的小工具选择使开发人员能够以一种简单的方式建立和部署视觉上有吸引力的、原生编译的应用程序,用于多个平台,包括移动、网络和桌面,都使用单一的代码库。因此,Flutter应用程序开发公司有更好的机会,可以确保你更快、更快、更可靠的应用程序开发解决方案。


事实上,Flutter早再2015年Dart开发者峰会上便以“Sky”的身份亮相,Flutter具有几大买点:首先它是免费的,而且是开源的;其次,该架构基于流行的反应式编程,因为它遵循与Reactive相同的风格;最后,归功于小部件体验,Flutter应用程序有一个令人愉快的UI,整体来说转化为应用程序看起来和感觉都不错。


我们再来看一下React Native,React Native也是Facebook在2015年推出的一个跨平台原生移动应用开发框架。React Native主要使用的是JavaScript开发语言,对于使用同一代码库为iOS和Android开发应用程序来说非常方便。此外,它的代码共享功能可以更快的开发和减少开发时间。像其他跨平台技术一样,Flutter允许开发者使用相同的代码库来构建独立的应用程序,因此,相比原生应用程序更容易维护。


当然,Flutter和React Native都支持热重载功能,允许开发者直接在运行中的应用程序中添加或纠正代码,而不必保存应用程序,从而加速了开发过程。除此之外,React Native是基于一种非常流行的语言--JavaScript,开发者更易上手;React组件包裹着现有的本地代码,并通过React的声明性UI范式和JavaScript与本地API进行交互,React Native的这些特点使开发人员的工作速度大大加快。


市场份额:五五开的格局正在改变


整体上来说,这两者的市场份额是十分相近的,但Flutter在最近有后来居上之势。2019年和2020年全球软件开发公司使用的最佳跨平台移动应用开发框架时,其结果是42%的开发者更愿意留在React Native,而39%的开发者选择了Flutter。根据StackOverFlow的数据,68.8%的开发者喜欢使用Flutter进行进一步的开发项目,而57.9%的开发者对使用React Native技术进行应用开发进一步表现出兴趣。


不同的市场报告有不同的统计数字,Flutter、React Native究竟孰强孰弱或许只能从一些市场趋势中窥见一二:




  • 市场趋势一:谷歌Google Trends的统计数字显示,在过去12个月的分析中,Flutter的搜索指数已反超React Native。




  • 市场趋势二:更年轻的Flutter在Github上拥有16.8万名成员和11.8万颗星的社区,而更成熟的React Native在Github仅有20.7万名成员和9.46万颗星。


    image.png




  • 趋势三:根据Statista的数据,React Native以42%的市场份额力压Flutter,但Flutter与React Native的差距正变得越来越小,其在一年内市场份额从30%急剧跃升至39%。




image.png


语言对比:Dart Vs JavaScript


Flutter所采用的Dart开发语言是谷歌2011年在丹麦奥尔胡斯举行的GOTO大会上亮相的,Dart是一门面向对象的、类定义的、单继承的语言,它的语法类似C语言,可以转译为JavaScript,支持接口(interfaces)、混入(mixins)、抽象类(abstract classes)、具体化泛型(reified generics)、可选类型(optional typing)和sound type system,并且具有AOT与JIT编译器,Dart的最大优势在于速度,运行比JavaScript快2倍,不过Dart作为一门较新的语言,开发者还需要熟悉Java或C++的应用程序开发工作才更易上手。


而React Native则采用的为已经在IT行业广泛应用多年的Javascript语言,类似于HTML的JSX,以及CSS来开发移动应用,因此熟悉Web前端开发的技术人员只需很少的学习就可以进入移动应用开发领域,不过JavaScript线程需要时间来初始化,所以导致React Native在最初渲染之前需要花费大量时间来初始化运行,不过React Native已经发布了升级线路,并且会在最近开源升级的版本,相信随着React Native新版本的发布,性能上将会追平Flutter。


技术架构


如果单从技术上讲,Flutter绝对是一个先进的跨平台技术方案,它提供了一个分层的架构,以确保高度的定制化,而React Native依赖于其他软件来构建反应组件,并使用JavaScriptBridge来桥接原生本地模块的连接。桥接会影响性能,即使发生轻微的变化,而Flutter可以在没有桥接的情况下管理一切。


Flutter提供的分层的架构,为简单快速的UI定制铺平了道路。它被认为可以让你完全控制屏幕上的每一个像素,并允许移动应用开发公司整合叠加和动画图形、文本、视频和控件,没有任何限制。


Flutter移动平台与其他Web平台的架构略有差异,不同平台相同的公共部分就是Dart部分,即Dart Framework。Flutter的公共部分主要实现了两个逻辑:第一,开发人员可以通过Flutter Ui系统编写UI,第二使用Dart虚拟机及Dart语言可以编写跟平台资源无关的逻辑。同时这也是Flutter跨平台的核心,和Java程序可以在Linux,Window,MacOs同时运行, Web程序可以在任意平台运行类似。通过Dart虚拟机,UI及和系统无光的逻辑都可以用Dart语言编写,运行在Dart虚拟机中,是跨平台的。


而React Native依赖于其他软件来构建反应组件,其架构整体上分为三大块:Native、JavaScript 与 Bridge,其中Native 管理UI 更新及交互,JavaScript 调用 Native 能力实现业务功能,Bridge 在二者之间传递消息。React Native 中主要有 3 个线程,应用中的主线程UI Thread、进行布局计算和构造 UI 界面的线程Shadow Thread与React 等 JavaScript 代码都在这个线程执行任务的JS Thread。


正因其依赖于其他软件来构建反应组件,因此在启动上会受到以下,必须先初始化 React Native 运行时环境(即Bridge),Bridge 准备好之后开始 run JS,最后开始 Native 渲染。从架构上来看,Flutter确实性能更高,也更符合当下跨平台开发的需求。


image.png


学习成本和社区支持


当涉及到构建企业应用程序时,社区支持是必须检查的因素。而React Native和Flutter都在行业中发展了多年,并且在谷歌与Facebook两大巨头的支持下都有最新的技术更新与广泛的社区支持。而随着每一个递增的版本和技术更新,社区对该框架的兴趣和需求逐渐增加。让我们了解一下这两个框架在社区参与方面的情况。


React Native在2015年推出,其社区一直处于成长阶段,Github上对该框架的贡献者数量就是证明。但是,尽管Flutter还很年轻,也比较新,但它正在已开始显示后来居上之势。


image.png


代码的可维护性


无论你开发的应用程序多么出色,为了使其顺利运行,不断地升级和调试是必要的。与Flutter相比,用React Native维护代码真的很困难。


在React Native中,当你为了开发适配不同系统的应用程序时就需要分开编写适配代码,它会干扰框架的逻辑,从而减慢了开发过程。另外,在React Native应用程序中,大多数本地组件都有一个第三方库的依赖性,所以维护这些过时的库确实是一个具有挑战性的任务。


对于Flutter来说,由于代码逻辑相对简单,不需要适配不同的操作系统,维护代码就要容易得多,允许移动应用程序开发人员轻松发现问题,为外部工具和支持第三方库提供数据支撑。


此外,与使用React Native的热重新加载功能相比,在Flutter中发布质量更新和对应用程序进行即时更改所花费的时间也比React Native表现更好。


开发成本


无论是一个初创公司还是一个先进的互联网企业,开发成本总是大家比较关心的内容。因此,当你选择雇用反应原生开发公司或Flutter应用程序工程师时,你可能需要评估他们的费率,不同的地方有不同的开发成本。


因此,在正式启动项目之前,无论是Flutter还是React Native,都需要考虑开发人员的素质,如经验、专业知识、项目处理等开发成本问题,以评估开发人员的实际小时费用,下面是Flutter和React Native的一个开发成本的问题。


image.png


除此之外,在选择Flutter还是React Native的问题上,我们还需要考虑他们的自定义开发能力。
Flutter和React Native都有一套属于自己的UI组件和小工具。并且,Flutter就以其漂亮的UI原生型小部件而闻名,这些小部件由框架的图形引擎进行渲染和管理。


而React Native只提供了适应平台的基本工具,如按钮、滑块、加载指示灯等基础组件,如果需要开发复杂的功能,就需要使用第三方组组件。


作者:Coolbreeze
链接:https://juejin.cn/post/7006283924473118756
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

当 Adapter 遇上 Kotlin DSL,无比简单的调用方式

早在去年的时候我就提到过使用工厂的方式获取 Adapter 而不是为每个 Adapter 定义一个类文件。这样的好处是,对于不是那么复杂的 Adapter 可以节省大量的代码,提升开发效率和解放双手,同时更好的支持多类型布局效果。 1、Kotlin DSL 和...
继续阅读 »

早在去年的时候我就提到过使用工厂的方式获取 Adapter 而不是为每个 Adapter 定义一个类文件。这样的好处是,对于不是那么复杂的 Adapter 可以节省大量的代码,提升开发效率和解放双手,同时更好的支持多类型布局效果。


1、Kotlin DSL 和 Adapter 工厂方法


可以把 Kotlin DSL 当作构建者使用。这里有一篇不错的文章,想了解的可以阅读下,



http://www.ximedes.com/2020-04-21/…



Kotlin DSL 是拓展函数的延申,比如我们常用的 with 等函数就是函数的拓展,


public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return receiver.block()
}

这里是泛型 T 的拓展。这里的 T 可以类比到 Java 构建者模式中的 Builder,通过方法接收外部参数之后调用 build() 方法创建一个最终的对象即可。


对于 Adapter 工厂方法,之前我是通过如下方式使用的,


fun <T> getAdapter(
@LayoutRes itemLayout:Int,
converter: (helper: BaseViewHolder, item: T) -> Unit,
data: List<T>
): Adapter<T> = Adapter(itemLayout, converter, data)

class Adapter<T>(
@LayoutRes private val layout: Int,
private val converter: (helper: BaseViewHolder, item: T) -> Unit,
val list: List<T>
): BaseQuickAdapter<T, BaseViewHolder>(layout, list) {
override fun convert(helper: BaseViewHolder, item: T) {
converter(helper, item)
}
}

也就是每次想要得到 Adapter 的时候只要调用 getAdapter() 方法即可。这种封装方式比较简陋,支持的功能有限。后来慢慢采用了 Kotlin DSL 之后,我封装了 Kotlin DSL 风格的工厂方法。采用 Kotlin DSL 风格之后更加优雅和方便快捷,同时更好的支持多类型布局效果。


2、使用


2.1 引入依赖


首先,该项目依赖于 BRVAH,所以,你需要引入该库之后才可以使用。BRVAH 可以说是目前开源的最好用的 Adapter,我们没必要再另起炉灶自己再造轮子。这个框架设计最好地方在于通过 SpareArray 收集了 ViewHolder 控件,从而避免了自定义 ViewHolder,这是我们框架设计的基础思想。


该项目已经上传到了 MavenCentral,你需要先在项目中引入该仓库,


allprojects {
repositories {
mavenCentral()
}
}

然后在项目中添加如下依赖,


implementation "com.github.Shouheng88:xadapter:${latest_version}"

2.2 使用 Adapter 工厂方法


使用 xAdapter 之后,当你需要定义一个 Adapter 的时候,你无需单独创建一个类文件,只需要通过 createAdapter() 方法获取一个 Adapter,


adapter = createAdapter {
withType(Item::class.java, R.layout.item_eyepetizer_home) {
// Bind data with viewholder.
onBind { helper, item ->
helper.setText(R.id.tv_title, item.data.title)
helper.setText(R.id.tv_sub_title, item.data.author?.name + " | " + item.data.category)
helper.loadCover(requireContext(), R.id.iv_cover, item.data.cover?.homepage, R.drawable.recommend_summary_card_bg_unlike)
helper.loadRoundImage(requireContext(), R.id.iv_author, item.data.author?.icon, R.mipmap.eyepetizer, 20f.dp2px())
}
// Item level click and long click events.
onItemClick { _, _, position ->
adapter?.getItem(position)?.let {
toast("Clicked item: " + it.data.title)
}
}
}
}

在这种新的调用方式中,你需要通过 withType() 方法指定数据类型及其对应的布局文件,然后在 onBind() 方法中即可实现数据到 ViewHolder 的绑定操作。这里的 onBind() 方法的使用与 BRVAH 中的 convert() 方法使用一致,可以通过阅读该库了解如何使用。总之,xAapter 在 BRVAH 的基础上做了二次封装,可以说,比简单更简单。


xAdapter 支持为每个 ViewHolder 绑定点击和长按事件,同时也支持为 ViewHolder 上的某个单独的 View 添加点击和长按事件。使用方式如上所示,只需要添加 onItemClick() 方法并实现自己的逻辑即可。其他的点击事件可以参考项目的示例代码。


效果,





2.3 使用多类型 Adapter


多类型 Adapter 的使用方式非常简单,类似于上面的调用方式,只需要在 createAdapter() 内再添加一个 withType() 方法即可。下面是一个写起来可能相当复杂的 Adapter,但是采用了 xAdpater 的调用方式之后,一切变得非常简单,


private fun createAdapter() {
adapter = createAdapter {
withType(MultiTypeDataGridStyle::class.java, R.layout.item_list) {
onBind { helper, item ->
val rv = helper.getView<RecyclerView>(R.id.rv)
rv.layoutManager = GridLayoutManager(context, 3)
val adapter = createSubAdapter(R.layout.item_home_page_data_module_1, 1)
rv.adapter = adapter
adapter.setNewData(item.items)
}
}
withType(MultiTypeDataListStyle1::class.java, R.layout.item_home_page_data_module_2) {
onBind { helper, item ->
converter.invoke(helper, item)
}
onItemClick { _, _, position ->
(adapter?.getItem(position) as? MultiTypeDataListStyle1)?.let {
toast("Clicked style[2] item: " + it.item.data.title)
}
}
}
withType(MultiTypeDataListStyle2::class.java, R.layout.item_list) {
onBind { helper, item ->
val rv = helper.getView<RecyclerView>(R.id.rv)
rv.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
val adapter = createSubAdapter(R.layout.item_home_page_data_module_4, 3)
rv.adapter = adapter
adapter.setNewData(item.items)
}
}
withType(MultiTypeDataListStyle3::class.java, R.layout.item_home_page_data_module_3) {
onBind { helper, item ->
converter.invoke(helper, item)
}
onItemClick { _, _, position ->
(adapter?.getItem(position) as? MultiTypeDataListStyle3)?.let {
toast("Clicked style[4] item: " + it.item.data.title)
}
}
}
}
}

xAdapter 对多类型布局方式的支持是在 BRVAH 之上进行的改造,在这种封装方式中,数据类无需实现任何类和接口。Adpater 内部通过 Class 区分各个 ViewHolder.


效果,





总结


相对于为各种类型的数据定义 Adapter 的使用方式,以上封装方式的优势是:



  1. 借助 BRVAH 的优势,封装了大量的方法,进一步简化了 Adapter 的使用;

  2. 通过工厂和 DSL 封装,简化了调用 Adapter 的方式,你无需为数据类型定义 Adapter 文件,减少了项目中需要维护的代码和类文件数量;

  3. 通过以上封装,使用 Adapter 更加简洁,节省了大量的代码,提升开发效率和解放双手;

  4. 自由地在单一类型布局和多类型布局之间进行切换,但是少了没必要的工厂方法。


当有更加简洁的使用方式的时候,继续采用复杂的调用方式无异于抱残守缺,对于程序员而言,做这种重复而没有太大价值的工作,付出再多的汗水都不值得同情。以上是部分功能和代码的展示,可以通过阅读源码了解更多。后续我参考其他优秀的库的设计思想,支持更多 Adapter 特性的封装来实现快速调用。


项目已开源,感兴趣的可以直接阅读项目源码,源码地址:github.com/Shouheng88/…


作者:shouheng
链接:https://juejin.cn/post/7006530777995296782
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Hook AMS + APT实现集中式登录框架

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。 本文链接:[juejin.cn/post/700695…) 1, 背景 登录功能是App开发中一个很常见的功能,一般存在两种登录方式: 一种是进入应用...
继续阅读 »

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:[juejin.cn/post/700695…)


1, 背景


登录功能是App开发中一个很常见的功能,一般存在两种登录方式:




  • 一种是进入应用就必须先登录才能使用(如聊天类软件)




  • 另一种是以游客身份使用,需要登录的时候才会去登录(如商城类软件)




针对第二种的登录方式,一般都是在要跳转到需要登录才能访问的页面(以下简称目标页面)时通过if-else判断是否已登录,未登录则跳转到登录界面,登录成功后退回到原界面,用户继续进行操作。伪代码如下:


if (需要登录) {
// 跳转到登录页面
} else {
// 跳转到目标页面
}

这中方式存在着以下几方面问题:



  1. 当项目功能逐渐庞大以后,存在大量重复的用于判断登录的代码,且判断逻辑可能分布在不同模块,维护成本很高。

  2. 增加或删除目标页面时需要修改判断逻辑,存在耦合。

  3. 跳转到登录页面,登录成功后只能退回到原界面,用户原本的意图被打断,需要再次点击才能进入目标界面(如:用户在个人中心界面点击“我的订单”按钮想要跳转到订单界面,由于没有登录就跳转到了登录界面,登录成功后返回个人中心界面,用户需要再次点击“我的订单”按钮才能进入订单界面)。


大致流程如下图所示:


login.png


针对传统登录方案存在的问题本文提出了一种通过Hook AMS + APT实现集中式登录方案。




  1. 首先通过Hook AMS实现集中处理判断,实现了跟业务逻辑解耦。




  2. 通过注解标记需要登录的页面,然后通过APT生成需要登录页面的集合,便于Hook中的判断。




  3. 最后在Hook AMS时将原意图放入登录页面的意图中,登录页面登录成功后可以获取到原意图,实现了继续用户原意图的目的。




本方案能达到的业务流程如下:


hook_login.png


1, 集中处理


这里借鉴插件化的思路通过Hook AMS实现拦截并统一处理的目的


1.1 分析Activity启动过程

了解Activity启动过程的应该都知道Activity中的startActivity()最终会进入Instrumentation


// Activity.java
@Override
public void startActivityForResult(
String who, Intent intent, int requestCode, @Nullable Bundle options) {
...
Instrumentation.ActivityResult ar =
mInstrumentation.execStartActivity(
this, mMainThread.getApplicationThread(), mToken, who,
intent, requestCode, options);
...
}

InstrumentationexecStartActivity代码如下:


public ActivityResult execStartActivity(
Context who, IBinder contextThread, IBinder token, String target,
Intent intent, int requestCode, Bundle options) {
...
try {
...
int result = ActivityManagerNative.getDefault()
.startActivity(whoThread, who.getBasePackageName(), intent,
intent.resolveTypeIfNeeded(who.getContentResolver()),
token, target, requestCode, 0, null, options);
checkStartActivityResult(result, intent);
} catch (RemoteException e) {
throw new RuntimeException("Failure from system", e);
}
return null;
}

其中调用了ActivityManagerNative.getDefault()startActivity(),那么此处getDefault()获取到的是什么?接着看代码:


/**
* Retrieve the system's default/global activity manager.
*/
static public IActivityManager getDefault() {
// step 1
return gDefault.get();
}

// step 2
private static final Singleton<IActivityManager> gDefault = new Singleton<IActivityManager>() {
protected IActivityManager create() {
// step 5
IBinder b = ServiceManager.getService("activity");
if (false) {
Log.v("ActivityManager", "default service binder = " + b);
}
IActivityManager am = asInterface(b);
if (false) {
Log.v("ActivityManager", "default service = " + am);
}
return am;
}
};

public abstract class Singleton<T> {
private T mInstance;

protected abstract T create();

// step 3
public final T get() {
synchronized (this) {
if (mInstance == null) {
// step 4
mInstance = create();
}
return mInstance;
}
}
}

gDefault是一个Singleton<IActivityManager>类型的静态常量,它的get()方法返回的是Singleton类中的private T mInstance;,这个mInstance的创建又是在gDefault实例化时通过create()方法实现。


这里代码有点绕,根据上面代码注释的step1 ~ 5,应该能理清楚:gDefault.get()获取到的mInstance实例就是ActivityManagerService(AMS)实例。


由于gDefault是一个静态常量,因此可以通过反射获取到它的实例,同时它是Singleton类型的,因此可以获取到其中的mInstance


到这里你应该能明白接下来要干什么了吧,没错就是Hook AMS。


1.2 Hook AMS


本文以android 6.0代码为例。注:8.0以下实现方式是相同的,8.0和9.0实现相同,10.0到12.0方式是一样的。


这里涉及到反射及动态代理的姿势,请自行了解。


1,获取gDefault实例


Class<?> activityManagerNative = Class.forName("android.app.ActivityManagerNative");
Field singletonField = activityManagerNative.getDeclaredField("gDefault");
singletonField.setAccessible(true);
// 获取gDefault实例
Object singleton = singletonField.get(null);

2,获取Singleton中的mInstance


Class<?> singletonClass = Class.forName("android.util.Singleton");
Field mInstanceField = singletonClass.getDeclaredField("mInstance");
mInstanceField.setAccessible(true);
/* Object mInstance = mInstanceField.get(singleton); */
Method getMethod = singletonClass.getDeclaredMethod("get");
Object mInstance = getMethod.invoke(singleton);

这里本可以直接通过mInstanceField及第一步中获取的gDefault实例反射得到mInstance实例,但是实测发现在Android 10以上无法获取,不过还好可以通过Singleton中的get()方法可以获取到其实例。


3,获取要动态代理的Interface


Class<?> iActivityManagerClass = Class.forName("android.app.IActivityManager");

4,创建一个代理对象


Object proxyInstance = Proxy.newProxyInstance(context.getClassLoader(), new Class[]{iActivityManagerClass},
(proxy, method, args) -> {
if (method.getName().equals("startActivity") && !isLogin()) {
// 拦截逻辑
}
return method.invoke(mInstance, args);
});

5,用代理对象替换原mInstance对象


mInstanceField.set(singleton, proxyInstance);

6,兼容性


针对8.0以下,8.0到9.0,10.0到12.0进行适配,可以兼容各个系统版本。


至此已经实现了对AMS的Hook,只需要在代理中判断当前要启动的Activity是否需要登录,然后跳转到登录即可。


但是此时出现了一个问题,这里如何判断哪些Activity需要登录的?最简单的方式就是写死,如下:


// 获取要启动的Activity的全类名。
String intentName = xxx
if (intentName.equals("aaaActivity")
|| intentName.equals("bbbActivity")
...
|| intentName.equals("xxxActivity")){
// 去登陆
}

这样的代码存在着耦合,添加删除目标Activity都需要改这里。


接下来就是通过APT实现解耦的方案。


2, APT实现解耦


APT就不多说了,就是注解处理器,很多流行框架都在用它,如果你不了解请自行了解。


首先定义注解,然后给目标Activity加上注解就相当于打了个标记,接着通过APT找到打了这些标记的Activity,将其全类名保存起来,最后在需要使用的地方通过反射调用即可。


2.1,定义注解


// 目标页面注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface RequireLogin {
// 需要登录的Activity加上该注解
}

// 登录页面注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginActivity {
// 给登录页面加上该注解,方便在Hook中直接调用
}

// 判断是否登录方法的注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface JudgeLogin {
// 给判断是否登录的方法添加注解,需要是静态方法。
}

2.2,注解处理器


这里就不贴代码了,重点是思路:


1,获取所有添加了RequireLogin注解的Activity,存入一个集合中


2,通过JavaPoet创建一个Class


3,在其中添加方法,返回1中集合里Activity的全类名的List


最终通过APT生成的类文件如下:


package me.wsj.login.apt;

public class AndLoginUtils {
// 需要登录的Activity的全类名集合
public static List<String> getNeedLoginList() {
List<String> result = new ArrayList<>();
result.add("me.wsj.andlogin.activity.TargetActivity1");
result.add("me.wsj.andlogin.activity.TargetActivity2");
return result;
}

// 登录Activity的全类名
public static String getLoginActivity() {
return "me.wsj.andlogin.activity.LoginActivity";
}

// 判断是否登录的方法全类名
public static String getJudgeLoginMethod() {
return "me.wsj.andlogin.activity.LoginActivity#checkLogin";
}
}

2.3,反射调用


在动态代理的InvocationHandler中通过反射获取


new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().equals("startActivity") && !isLogin()) {
// 目标Activity全类名
String intentName = xxx;
if (isRequireLogin(intentName)) {
// 该Activity需要登录,跳转到登录页面
}
}
return null;
}
}

/**
* 该activity是否需要登录
*
* @param activityName
* @return
*/
private static boolean isRequireLogin(String activityName) {
if (requireLoginNames.size() == 0) {
// 反射调用apt生成的方法
try {
Class<?> NeedLoginClazz = Class.forName(UTILS_PATH);
Method getNeedLoginListMethod = NeedLoginClazz.getDeclaredMethod("getRequireLoginList");
getNeedLoginListMethod.setAccessible(true);
requireLoginNames.addAll((List<String>) getNeedLoginListMethod.invoke(null));
Log.d("HootUtil", "size" + requireLoginNames.size());
} catch (Exception e) {
e.printStackTrace();
}
}
return requireLoginNames.contains(activityName);
}

2.4,其他


实现了判断目标页面的解耦,同样的方式也可以实现跳转登录及判断是否登录的解耦。


1,跳转登录页面


前面定义了LoginActivity()注解,APT也生成了getLoginActivity()方法,那就可以反射获取到配置的登录Activity,然后创建新的Intent,替换掉原Intent,进而实现跳转到登录页面。


if (需要跳转到登录) {
Intent intent = new Intent(context, getLoginActivity());
// 然后需要将该intent替换掉原intent接口
}

/**
* 获取登录activity
*
* @return
*/
private static Class<?> getLoginActivity() {
if (loginActivityClazz == null) {
try {
Class<?> NeedLoginClazz = Class.forName(UTILS_PATH);
Method getLoginActivityMethod = NeedLoginClazz.getDeclaredMethod("getLoginActivity");
getLoginActivityMethod.setAccessible(true);
String loginActivity = (String) getLoginActivityMethod.invoke(null);
loginActivityClazz = Class.forName(loginActivity);
} catch (Exception e) {
e.printStackTrace();
}
}
return loginActivityClazz;
}

2,判断是否登录


同理为了实现对判断是否登录的解耦,在判断是否能登录的方法上添加一个JudgeLogin注解,就可以在Hook中反射调用判断。当然这里也可以通过添加回调的方式实现。


2.5,小结


通过APT实现了对判断是否登录、判断哪些页面需要登录及跳转登录的解耦。


此时面临着最后一个问题,虽然前面已经实现了拦截并跳转到了登录页面,但是登录完成后再返回到原页面看似合理,实则不XXXX(词穷了,自行脑补😂),用户的意图被打断了。


接着就看看如何在登录成功后继续用户意图。


3, 继续用户意图


由于Intent实现了Parcelable接口,因此可以将它作为一个Intent的Extra参数传递。在Hook过程中可以获取原始Intent,因此只需在Hook中将用户的原始意图Intent作为一个附加参数存入跳转登录的Intent中,然后在登录页面获取到这个参数,登录成功后跳转到这个原始Intent即可。


1,传递原始意图


在动态代理中先拿到原始Intent,然后将它作为参数存入新的Intent中


new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().equals("startActivity") && !isLogin()) {
// 目标Activity全类名
Intent originIntent = xxx;
String intentName = xxx;
if (isRequireLogin(intentName)) {
// 该Activity需要登录,跳转到登录页面
Intent intent = new Intent(context, getLoginActivity());
intent.putExtra(Constant.Hook_AMS_EXTRA_NAME, originIntent);
// 然后替换原Intent
...
}
}
return null;
}
}

2,获取原始意图并跳转


在登录页面,登录成功后判断其intent中是否有特定键值的附加数据,如果有则直接用它作为意图启动新页面,实现了继续用户意图的目的;


@LoginActivity
class LoginActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

...
binding.btnLogin.setOnClickListener {
// 登录成功了
var targetIntent = intent.getParcelableExtra<Intent>(AndLogin.TARGET_ACTIVITY_NAME)
// 如果存在targetIntent则启动目标intent
if (targetIntent != null) {
startActivity(targetIntent)
}
finish()
}
}

companion object {
// 该方法用于返回是否登录
@JudgeLogin
@JvmStatic
fun checkLogin(): Boolean {
return SpUtil.isLogin()
}
}
}

如上所示,如果可以在当前Intent中获取到Hook时保存的数据,则说明存在目标Intent,只需将其启动即可。


看一下最终效果:


preview.gif


4, ARouter方案


熟悉ARouter的都知道,它有一个拦截器的东西,可以在跳转前做拦截操作。如下:


@Interceptor(name = "login", priority = 1)
public class LoginInterceptorImpl implements IInterceptor {
@Override
public void process(Postcard postcard, InterceptorCallback callback) {
...
if (isLogin) { // 已经登录不拦截
callback.onContinue(postcard);
} else { // 未登录则拦截
// callback.onInterrupt(null);
}
}

@Override
public void init(Context context) {
}
}

实现IInterceptor接口并添加Interceptor注解即可在路由跳转时实现拦截。


了解其原理的话可知:ARouter也只是在启动Activity前提供了拦截判断的时机,相当于本方案的第一步(Hook AMS)操作,后续实现解耦以及继续用户意图操作还需要自己实现。


5, 总结


本文提出了一种通过Hook AMS + APT实现集中式登录的方案,对比传统方式本方案存在以下优势:




  1. 以非侵入性的方式将分散的登录判断逻辑集中处理,减少了代码量,提高了开发效率。




  2. 增加或删除目标页面时无需修改判断逻辑,只需增加或删除其对应注解即可,符合开闭原则,降低了耦合度




  3. 在用户登录成功后直接跳转到目标界面,保证了用户操作不被中断。




本方案并没有太高深的东西,只是把常用的东西整合在一起,综合运用了一下。另外方案只是针对需要跳转页面的情况,对于判断是否登录后做其他操作的,比如弹出一个Toast这样的操作,可以通过AspectJ等来实现。


项目地址:github.com/wdsqjq/AndL…


最后,本方案提供了远程依赖,使用startup实现了无侵入初始化,使用方式如下:


1,添加依赖


allprojects {
repositories {
maven { url 'https://www.jitpack.io' }
}
}


dependencies {
implementation 'com.github.wdsqjq.AndLogin:lib:1.0.0'
kapt 'com.github.wdsqjq.AndLogin:apt_processor:1.0.0'
}

2,给需要登录的Activity添加注解


@RequireLogin
class TargetActivity1 : AppCompatActivity() {
...
}

@RequireLogin
class TargetActivity2 : AppCompatActivity() {
...
}


3,给登录Activity添加注解


@LoginActivity
class LoginActivity : AppCompatActivity() {
...
}

4,提供判断是否登录的方法


需要是一个静态方法


@LoginActivity
class LoginActivity : AppCompatActivity() {

companion object {
// 该方法用于返回是否登录
@JudgeLogin
@JvmStatic
fun checkLogin(): Boolean {
return SpUtil.isLogin()
}
}
}

作者:giswangsj
链接:https://juejin.cn/post/7006951885089292296
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

一文彻底搞懂js中的位置计算

引言 文章中涉及到的api列表:scroll相关Apiclient相关Apioffset相关ApiElement.getBoundingClientRectAPiWindow.getComputedStyleApi 我们会结合api定义,知名开源库中的应用场...
继续阅读 »

引言


文章中涉及到的api列表:

scroll相关Api

client相关Api

offset相关Api

Element.getBoundingClientRectAPi

Window.getComputedStyleApi



我们会结合api定义,知名开源库中的应用场景来逐层分析这些api。足以应对工作中关于元素位置计算的大部分场景。




注意在使用位置计算api时要格外的小心,不合理的使用他们可能会造成布局抖动Layout Thrashing影响页面渲染。



scroll


首先我们先来看看scroll相关的属性和方法。


Element.scroll()


Element.scroll()方法是用于在给定的元素中滚动到某个特定坐标的Element 接口。


element.scroll(x-coord, y-coord)
element.scroll(options)


  • x-coord 是指在元素左上方区域横轴方向上想要显示的像素。

  • y-coord 是指在元素左上方区域纵轴方向上想要显示的像素。


也就是element.scroll(x,y)会将元素滚动条位置滚动到对应x,y的位置。


同时也支持element.scroll(options)方式调用,支持传入额外的配置:


{
left: number,
top: number,
behavior: 'smooth' | 'auto' // 平滑滚动还是默认直接滚动
}

Element.scrollHeight/scrollWidth



  • Element.scrollHeight 这个只读属性是一个元素内容高度的度量,包括由于溢出导致的视图中不可见内容。



scrollHeight 的值等于该元素在不使用滚动条的情况下为了适应视口中所用内容所需的最小高度。 没有垂直滚动条的情况下,scrollHeight值与元素视图填充所有内容所需要的最小值clientHeight相同。包括元素的padding,但不包括元素的border和margin。scrollHeight也包括 ::before 和 ::after这样的伪元素。



换句话说Element.scrollHeight在元素不存在滚动条的情况下是恒等于clientHeight的。


但是如果出现了滚动条的话scrollHeight指的是包含元素不可以见内容的高度,出现滚动条的情况下是scrollHeight恒大于clientHeight



  • Element.scrollWidth 这也是一个元素内容宽度的只读属性,包含由于溢出导致视图中不可以见的内容。



原理上和scrollHeight是同理的,只不过这里是宽度而非高度。



简单来说一个元素如果不存在滚动条,那么他们的scrollclient都是相等的值。如果存在了滚动条,client只会计算出当前元素展示出来的高度/宽度,而scroll不仅仅会计算当前元素展示出的,还会包含当前元素的滚动条隐藏内容的高度/宽度。


clientWidth/height + [滚动条被隐藏内容宽度/高度] = scrollWidth/Height


Element.scrollLeft/scrollTop




  • Element.scrollTop 属性可以获取或设置一个元素的内容垂直滚动的像素数.




  • Element.scrollLeft 属性可以读取或设置元素滚动条到元素左边的距离.





需要额外注意的是: 注意如果这个元素的内容排列方向(direction) 是rtl (right-to-left) ,那么滚动条会位于最右侧(内容开始处),并且scrollLeft值为0。此时,当你从右到左拖动滚动条时,scrollLeft会从0变为负数。



scrollLeft/Top在日常工作中是比较频繁使用关于操作滚动条的相关api,他们是一个可以设置的值。根据不同的值对应可以控制滚动条的位置。


其实这两个属性和上方的Element.scroll()可以达到相同的效果。



在实际工作中如果对于滚动操作有很频繁的需求,个人建议去使用better-scroll,它是一个移动/web端的通用js滚动库,内部是基于元素transform去操作的滚动并不会触发相关重塑/回流。



判断当前元素是否存在滚动条



出现滚动条便意味着元素空间将大于其内容显示区域,根据这个现象便可以得到判断是否出现滚动条的规则。



export const hasScrolled = (element, direction) => {
if (!element || element.nodeType !== 1) return;
if (direction === "vertical") {
return element.scrollHeight > element.clientHeight;
} else if (direction === "horizontal") {
return element.scrollWidth > element.clientWidth;
}
};

判断用户是否滚动到底部



本质上就是当元素出现滚动条时,判断当前元素出现的高度 + 滚动条高度 = 元素本身的高度(包含隐藏部分)



element.scrollHeight - element.scrollTop === element.clientHeight

client


MouseEvent.clientX/Y


MounseEvent.clientX/Y同样也是只读属性,它提供事件发生时的应用客户端区域的水平坐标。



例如,不论页面是否有垂直/水平滚动,当你点击客户端区域的左上角时,鼠标事件的 clientX/Y 值都将为 0 。



其实MouseEvent.clientX/Y也就是相对于当前视口(浏览器可视区)进行位置计算。


转载一张非常直白的图:


clientX


Element.clientHeight/clientWidth


Element.clientWidth/clinetHeight 属性表示元素的内部宽度,以像素计。该属性包括内边距 padding,但不包括边框 border、外边距 margin 和垂直滚动条(如果有的话)。



内联元素以及没有 CSS 样式的元素的 clientWidth 属性值为 0。



在不出现滚动条时候Element.clientWidth/Height === Element.scrollWidth/Height


image.png


Element.clientTop/clientLeft


Element.clientLeft表示一个元素的左边框的宽度,以像素表示。如果元素的文本方向是从右向左(RTL, right-to-left),并且由于内容溢出导致左边出现了一个垂直滚动条,则该属性包括滚动条的宽度。clientLeft 不包括左外边距和左内边距。clientLeft 是只读的。


同样的Element.clientTop表示元素上边框的宽度,也是一个只读属性。



这两个属性日常使用会比较少,但是也应该了解以避免搞混这些看似名称都类似的属性。



offset


MouseEvent.offsetX/offsetY


MouseEvent 接口的只读属性 offsetX/Y 规定了事件对象与目标节点的内填充边(padding edge)在 X/Y 轴方向上的偏移量。


相信使用过offest的同学对这个属性深有体会,它是相对于父元素的左边/上方的偏移量。



注意是触发元素也就是 e.target,额外小心如果事件对象中存在从一个子元素当移动到子元素内部时,e.offsetX/Y 此时相对于子元素的左上角偏移量。



offsetWidth/offsetHeight


HTMLElement.offsetWidth/Height 是一个只读属性,返回一个元素的布局宽度/高度。


所谓的布局宽度也就是相对于我们上边说到的clientHeight/Width,offsetHeight/Width,他们都是不包含border以及滚动条的宽/高(如果存在的话)。


offsetWidth/offsetHeight返回元素的布局宽度/高度,包含元素的边框(border)、水平线/垂直线上的内边距(padding)、竖直/水平方向滚动条(scrollbar)(如果存在的话)、以及CSS设置的宽度(width)的值


offsetTop/left


HTMLElement.offsetLeft 是一个只读属性,返回当前元素左上角相对于 HTMLElement.offsetParent 节点的左边界偏移的像素值。



注意返回的是相对于 HTMLElement.offsetParent 节点左边边界的偏移量。



何为HTMLElement.offsetParent?



HTMLElement.offsetParent 是一个只读属性,返回一个指向最近的(指包含层级上的最近)包含该元素的定位元素或者最近的 table,td,th,body 元素。当元素的 style.display 设置为 "none" 时,offsetParent 返回 null。offsetParent 很有用,因为 offsetTop 和 offsetLeft 都是相对于其内边距边界的。 -- MDN



讲讲人话,当前元素的祖先组件节点如果不存在任何 table,td,th 以及 position 属性为 relative,absolute 等为定位元素时,offsetLeft/offsetTop 返回的是距离 body 左/上角的偏移量。


当祖先元素中有定位元素(或者上述标签元素)时,它就可以被称为元素的offsetParent。元素的 offsetLeft/offsetTop 的值等于它的左边框左侧/顶边框顶部到它的 offsetParent 元素左边框的距离。


我们来看看这张图:


image.png


计算元素距离 body 的偏移量


当我们需要获得元素距离 body 的距离时,但是又无法确定父元素是否存在定位元素时(大多数时候在组件开发中,并不清楚父节点是否存在定位)。此时需要实现类似 jqery 的 offset()方法:获得当前元素对于 body 的偏移量。



  • 无法直接使用 offsetLeft/offsetTop 获取,因为并不确定父元素是否存在定位元素。

  • 使用递归解决,累加偏移量 offset,当前 offsetParent 不为 body 时。

  • 继续递归向上超着 offsetParent 累加 offset,直到遇到 body 元素停止。


const getOffsetSize = function(Node: any, offset?: any): any {
if (!offset) {
offset = {
x: 0,
y: 0
};
}
if (Node === document.body) return offset;
offset.x = offset.x + Node.offsetLeft;
offset.y = offset.y + Node.offsetTop;
return getOffsetSize(Node.offsetParent, offset);
};


注意:这里不可以使用 parentNode 上文已经讲过 offsetLeft/top 针对的是 HTMLElement.offsetParent 的偏移量而非 parentNode 的偏移量。



Element.getBoundingClientRect


用法讲解


Element.getBoundingClientRect() 方法返回元素的大小及其相对于视口的位置。



element.getBoundingClientRect()返回的相对于视口左上角的位置。



element.getBoundingClientRect()返回的 heightwidth 是针对元素可见区域的宽和高(具体尺寸根据 box-sizing 决定),并不包含滚动条被隐藏的内容。



TIP: 如果是标准盒子模型,元素的尺寸等于 width/height + padding + border-width 的总和。如果 box-sizing: border-box,元素的的尺寸等于 width/height。



rectObject = object.getBoundingClientRect();

返回值是一个 DOMRect 对象,这个对象是由该元素的 getClientRects() 方法返回的一组矩形的集合,就是该元素的 CSS 边框大小。返回的结果是包含完整元素的最小矩形,并且拥有 left, top, right, bottom, x, y, width, 和 height 这几个以像素为单位的只读属性用于描述整个边框。除了 widthheight 以外的属性是相对于视图窗口的左上角来计算的。


widthheight是计算元素的大小,其他属性都是相对于视口左上角来说的。


当计算边界矩形时,会考虑视口区域(或其他可滚动元素)内的滚动操作,也就是说,当滚动位置发生了改变,top 和 left 属性值就会随之立即发生变化(因此,它们的值是相对于视口的,而不是绝对的) 。如果你需要获得相对于整个网页左上角定位的属性值,那么只要给 top、left 属性值加上当前的滚动位置(通过 window.scrollX 和 window.scrollY),这样就可以获取与当前的滚动位置无关的值。


image.png


计算元素是否出现在视口内


利用的还是元素距离视口的位置小于视口的大小。



注意即便变成了负值,那么也表示元素曾经出现过在屏幕中只是现在不显示了而已。(就比如滑动过)



vue-lazy图片懒加载库源码就是这么判断的。


 isInView (): boolean {
const rect = this.el.getBoundingClientRect()
return rect.top < window.innerHeight && rect.left < window.innerWidth
}


如果rect.top < window.innerHeight表示当前元素已经已经出现在(过)页面中,left同理。



window.getComputedStyle


用法讲解


Window.getComputedStyle()方法返回一个对象,该对象在应用活动样式表并解析这些值可能包含的任何基本计算后报告元素的所有CSS属性的值。 私有的CSS属性值可以通过对象提供的API或通过简单地使用CSS属性名称进行索引来访问。


let style = window.getComputedStyle(element, [pseudoElt]);



  • element


     用于获取计算样式的Element




  • pseudoElt 可选


    指定一个要匹配的伪元素的字符串。必须对普通元素省略(或null)。




返回的style是一个实时的 CSSStyleDeclaration 对象,当元素的样式更改时,它会自动更新本身。


作者:19组清风
链接:https://juejin.cn/post/7006878952736161829

收起阅读 »

面试贼坑的十道js面试题(我只会最后一题)

前言 现在前端面试经常遇到奇葩的题,有的听都没听过,何谈能答对,这些是小伙伴们投稿的题,大家来看看,出这些题的人,都优秀到不行啊,想要拿到满意的offer,不得不卷啊,头疼一批 typeof null 为什么是object null就出了一个 bug。...
继续阅读 »

前言



  • 现在前端面试经常遇到奇葩的题,有的听都没听过,何谈能答对,这些是小伙伴们投稿的题,大家来看看,出这些题的人,都优秀到不行啊,想要拿到满意的offer,不得不卷啊,头疼一批


typeof null 为什么是object




  • null就出了一个 bug。根据 type tags 信息,低位是 000,因此 null被判断成了一个对象。这就是为什么 typeofnull的返回值是 "object"。




  • 关于 null的类型在 MDN 文档中也有简单的描述:typeof - java | MDN




  • 在 ES6 中曾有关于修复此 bug 的提议,提议中称应该让 typeofnull==='null'wiki.ecma.org/doku.php?id… 但是该提议被无情的否决了,自此 typeofnull终于不再是一个 bug,而是一个 feature,并且永远不会被修复




0.1+0.2为什么不等于0.3,以及怎么等于0.3



  • 在开发过程中遇到类似这样的问题:


let n1 = 0.1, n2 = 0.2
console.log(n1 + n2) // 0.30000000000000004


  • 这里得到的不是想要的结果,要想等于0.3,就要把它进行转化:


(n1 + n2).toFixed(2) // 注意,toFixed为四舍五入

toFixed(num) 方法可把 Number 四舍五入为指定小数位数的数字。那为什么会出现这样的结果呢?


计算机是通过二进制的方式存储数据的,所以计算机计算0.1+0.2的时候,实际上是计算的两个数的二进制的和。0.1的二进制是0.0001100110011001100...(1100循环),0.2的二进制是:0.00110011001100...(1100循环),这两个数的二进制都是无限循环的数。那JavaScript是如何处理无限循环的二进制小数呢?


一般我们认为数字包括整数和小数,但是在 JavaScript 中只有一种数字类型:Number,它的实现遵循IEEE 754标准,使用64位固定长度来表示,也就是标准的double双精度浮点数。在二进制科学表示法中,双精度浮点数的小数部分最多只能保留52位,再加上前面的1,其实就是保留53位有效数字,剩余的需要舍去,遵从“0舍1入”的原则。


根据这个原则,0.1和0.2的二进制数相加,再转化为十进制数就是:0.30000000000000004


下面看一下双精度数是如何保存的:


2020080420355853.png



  • 第一部分(蓝色):用来存储符号位(sign),用来区分正负数,0表示正数,占用1位

  • 第二部分(绿色):用来存储指数(exponent),占用11位

  • 第三部分(红色):用来存储小数(fraction),占用52位


对于0.1,它的二进制为:


0.00011001100110011001100110011001100110011001100110011001 10011...

转为科学计数法(科学计数法的结果就是浮点数):


1.1001100110011001100110011001100110011001100110011001*2^-4

可以看出0.1的符号位为0,指数位为-4,小数位为:


1001100110011001100110011001100110011001100110011001

那么问题又来了,指数位是负数,该如何保存呢?


IEEE标准规定了一个偏移量,对于指数部分,每次都加这个偏移量进行保存,这样即使指数是负数,那么加上这个偏移量也就是正数了。由于JavaScript的数字是双精度数,这里就以双精度数为例,它的指数部分为11位,能表示的范围就是0~2047,IEEE固定双精度数的偏移量为1023



  • 当指数位不全是0也不全是1时(规格化的数值),IEEE规定,阶码计算公式为 e-Bias。 此时e最小值是1,则1-1023= -1022,e最大值是2046,则2046-1023=1023,可以看到,这种情况下取值范围是-1022~1013

  • 当指数位全部是0的时候(非规格化的数值),IEEE规定,阶码的计算公式为1-Bias,即1-1023= -1022。

  • 当指数位全部是1的时候(特殊值),IEEE规定这个浮点数可用来表示3个特殊值,分别是正无穷,负无穷,NaN。 具体的,小数位不为0的时候表示NaN;小数位为0时,当符号位s=0时表示正无穷,s=1时候表示负无穷。


对于上面的0.1的指数位为-4,-4+1023 = 1019 转化为二进制就是:1111111011.


所以,0.1表示为:


0 1111111011 1001100110011001100110011001100110011001100110011001

说了这么多,是时候该最开始的问题了,如何实现0.1+0.2=0.3呢?


对于这个问题,一个直接的解决方法就是设置一个误差范围,通常称为“机器精度”。对JavaScript来说,这个值通常为2-52,在ES6中,提供了Number.EPSILON属性,而它的值就是2-52,只要判断0.1+0.2-0.3是否小于Number.EPSILON,如果小于,就可以判断为0.1+0.2 ===0.3


function numberepsilon(arg1,arg2){                   
return Math.abs(arg1 - arg2) < Number.EPSILON;
}

console.log(numberepsilon(0.1 + 0.2, 0.3)); // true

为什么要用weakMap




  • WeakMap 为弱引用,利于垃圾回收机制。




  • 一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。Weakmap 保存的这个键值对,也会自动消失。




  • 总之,WeakMap的专用场合就是,它的键所对应的对象,可能会在将来消失。WeakMap结构有助于防止内存泄漏。




RAF 和 RIC 是什么



  • requestAnimationFrame: 告诉浏览器在下次重绘之前执行传入的回调函数(通常是操纵 dom,更新动画的函数);由于是每帧执行一次,那结果就是每秒的执行次数与浏览器屏幕刷新次数一样,通常是每秒 60 次。

  • requestIdleCallback:: 会在浏览器空闲时间执行回调,也就是允许开发人员在主事件循环中执行低优先级任务,而不影响一些延迟关键事件。如果有多个回调,会按照先进先出原则执行,但是当传入了 timeout,为了避免超时,有可能会打乱这个顺序。


escape、encodeURI、encodeURIComponent 的区别



  • encodeURI 是对整个 URI 进行转义,将 URI 中的非法字符转换为合法字符,所以对于一些在 URI 中有特殊意义的字符不会进行转义。

  • encodeURIComponent 是对 URI 的组成部分进行转义,所以一些特殊字符也会得到转义。

  • escape 和 encodeURI 的作用相同,不过它们对于 unicode 编码为 0xff 之外字符的时候会有区别,escape 是直接在字符的 unicode 编码前加上 %u,而 encodeURI 首先会将字符转换为 UTF-8 的格式,再在每个字节前加上 %。


await 到底在等啥


await 在等待什么呢? 一般来说,都认为 await 是在等待一个 async 函数完成。不过按语法说明,await 等待的是一个表达式,这个表达式的计算结果是 Promise 对象或者其它值(换句话说,就是没有特殊限定)。


因为 async 函数返回一个 Promise 对象,所以 await 可以用于等待一个 async 函数的返回值——这也可以说是 await 在等 async 函数,但要清楚,它等的实际是一个返回值。注意到 await 不仅仅用于等 Promise 对象,它可以等任意表达式的结果,所以,await 后面实际是可以接普通函数调用或者直接量的。所以下面这个示例完全可以正确运行:


function getSomething() {
return "something";
}
async function testAsync() {
return Promise.resolve("hello async");
}
async function test() {
const v1 = await getSomething();
const v2 = await testAsync();
console.log(v1, v2);
}
test();

await 表达式的运算结果取决于它等的是什么。



  • 如果它等到的不是一个 Promise 对象,那 await 表达式的运算结果就是它等到的东西。

  • 如果它等到的是一个 Promise 对象,await 就忙起来了,它会阻塞后面的代码,等着 Promise 对象 resolve,然后得到 resolve 的值,作为 await 表达式的运算结果。


来看一个例子:


function testAsy(x){
return new Promise(resolve=>{setTimeout(() => {
resolve(x);
}, 3000)
}
)
}
async function testAwt(){
let result = await testAsy('hello world');
console.log(result); // 3秒钟之后出现hello world
console.log('cuger') // 3秒钟之后出现cug
}
testAwt();
console.log('cug') //立即输出cug

这就是 await 必须用在 async 函数中的原因。async 函数调用不会造成阻塞,它内部所有的阻塞都被封装在一个 Promise 对象中异步执行。await暂停当前async的执行,所以'cug''最先输出,hello world'和‘cuger’是3秒钟后同时出现的。


|| 和 && 操作符的返回值



  • || 和 && 首先会对第一个操作数执行条件判断,如果其不是布尔值就先强制转换为布尔类型,然后再执行条件判断。

  • 对于 || 来说,如果条件判断结果为 true 就返回第一个操作数的值,如果为 false 就返回第二个操作数的值。

  • && 则相反,如果条件判断结果为 true 就返回第二个操作数的值,如果为 false 就返回第一个操作数的值。

  • || 和 && 返回它们其中一个操作数的值,而非条件判断的结果


2 == [[[2]]]



  • 根据ES5规范,如果比较的两个值中有一个是数字类型,就会尝试将另外一个值强制转换成数字,再进行比较。而数组强制转换成数字的过程会先调用它的 toString方法转成字符串,然后再转成数字。所以 [2]会被转成 "2",然后递归调用,最终 [[[2]]] 会被转成数字 2。


var x = [typeof x, typeof y][1];typeof typeof x;//"string"




  • 因为没有声明过变量y,所以typeof y返回"undefined"




  • 将typeof y的结果赋值给x,也就是说x现在是"undefined"




  • 然后typeof x当然是"string"




  • 最后typeof "string"的结果自然还是"string"




你能接受加班吗?而且我们加班不给钱!



  • f¥¥¥¥¥k y**********u

链接:https://juejin.cn/post/7005402640746020877

收起阅读 »

for 循环不是目的,map 映射更有意义!【FP探究】

楔子 在 JavaScript 中,由于 Function 本质也是对象(这与 Haskell 中【函数的本质是值】思路一致),所以我们可以把 Function 作为参数来进行传递! 例🌰: function sayHi() { console.log("...
继续阅读 »

楔子


在 JavaScript 中,由于 Function 本质也是对象(这与 Haskell 中【函数的本质是值】思路一致),所以我们可以把 Function 作为参数来进行传递


例🌰:


function sayHi() {
console.log("Hi");
}
function sayBye() {
console.log("Bye");
}

function greet(type, sayHi, sayBye) {
type === 1 ? sayHi() : sayBye()
}

greet(1, sayHi, sayBye); // Hi

又得讲这个老生常谈的定义:如果一个函数“接收函数作为参数”或“返回函数作为输出”,那么这个函数被称作“高阶函数”


本篇要谈的是:高阶函数中的 mapfilterreduce 是【如何实践】的,我愿称之为:高阶映射!!


先别觉得这东西陌生,其实咱们天天都见!!


例🌰:


[1,2,3].map(item => item*2)

实践



Talk is cheap. Show me the code.



以下有 4 组代码,每组的 2 个代码片段实现目标一致,但实现方式有异,感受感受,你更喜欢哪个?💖


第 1 组:


1️⃣


const arr1 = [1, 2, 3];
const arr2 = [];
for(let i = 0; i < arr1.length; i++) {
arr2.push(arr1[i] * 2);
}
console.log(arr2); // [ 2, 4, 6 ]

2️⃣


const arr1 = [1, 2, 3];
const arr2 = arr1.map(item => item * 2);
console.log(arr2); // [ 2, 4, 6 ]

第 2 组:


1️⃣


const birthYear = [1975, 1997, 2002, 1995, 1985];
const ages = [];
for(let i = 0; i < birthYear.length; i++) {
let age = 2018 - birthYear[i];
ages.push(age);
}
console.log(ages); // [ 43, 21, 16, 23, 33 ]

2️⃣


const birthYear = [1975, 1997, 2002, 1995, 1985];
const ages = birthYear.map(year => 2018 - year);
console.log(ages); // [ 43, 21, 16, 23, 33 ]

第 3 组:


1️⃣


const persons = [
{ name: 'Peter', age: 16 },
{ name: 'Mark', age: 18 },
{ name: 'John', age: 27 },
{ name: 'Jane', age: 14 },
{ name: 'Tony', age: 24},
];
const fullAge = [];
for(let i = 0; i < persons.length; i++) {
if(persons[i].age >= 18) {
fullAge.push(persons[i]);
}
}
console.log(fullAge);

2️⃣


const persons = [
{ name: 'Peter', age: 16 },
{ name: 'Mark', age: 18 },
{ name: 'John', age: 27 },
{ name: 'Jane', age: 14 },
{ name: 'Tony', age: 24},
];
const fullAge = persons.filter(person => person.age >= 18);
console.log(fullAge);

第 4 组:


1️⃣


const arr = [5, 7, 1, 8, 4];
let sum = 0;
for(let i = 0; i < arr.length; i++) {
sum = sum + arr[i];
}
console.log(sum); // 25

2️⃣


const arr = [5, 7, 1, 8, 4];
const sum = arr.reduce(function(accumulator, currentValue) {
return accumulator + currentValue;
});
console.log(sum); // 25

更喜欢哪个?有答案了吗?


image.png


每组的代码片段 2️⃣ 就是map/filter/reduce高阶函数的应用,没有别的说的,就是更加简洁易读


手写


实际上,map/filter/reduce 也是基于 for 循环封装来的,所以我们也能自己实现一套相同的 高阶映射 🚀;



  • map1


Array.prototype.map1 = function(fn) {
let newArr = [];
for (let i = 0; i < this.length; i++) {
newArr.push(fn(this[i]))
};
return newArr;
}

console.log([1,2,3].map1(item => item*2)) // [2,4,6]


  • filter1


Array.prototype.filter1 = function (fn) {
let newArr=[];
for(let i=0;i<this.length;i++){
fn(this[i]) && newArr.push(this[i]);
}
return newArr;
};

console.log([1,2,3].filter1(item => item>2)) // [3]


  • reduce1


Array.prototype.reduce1 = function (reducer,initVal) {
for(let i=0;i<this.length;i++){
initVal =reducer(initVal,this[i],i,this);
}
return initVal
};

console.log([1,2,3].reduce1((a,b)=>a+b,0)) // 6

如果你不想直接挂在原型链上🛸:



  • mapForEach


function mapForEach(arr, fn) {
const newArray = [];
for(let i = 0; i < arr.length; i++) {
newArray.push(
fn(arr[i])
);
}
return newArray;
}

mapForEach([1,2,3],item=>item*2) // [2,4,6]


  • filterForEach


function filterForEach(arr, fn) {
const newArray = [];
for(let i = 0; i < arr.length; i++) {
fn(arr[i]) && newArray.push(arr[i]);
}
return newArray;
}

filterForEach([1,2,3],item=>item>2) // [3]


  • reduceForEach


function reduceForEach(arr,reducer,initVal) {
const newArray = [];
for(let i = 0; i < arr.length; i++) {
initVal =reducer(initVal,arr[i],i,arr);
}
return initVal;
}

reduceForEach([1,2,3],(a,b)=>a+b,0) // 6

这里本瓜有个小疑惑,在 ES6 之前,有没有一个库做过这样的封装❓


小结


本篇虽基础,但很重要


对一些惯用写法的审视、改变,会产生一些奇妙的思路~ 稀松平常的 map 映射能做的比想象中的要多得多!


for 循环遍历只是操作性的手段,不是目的!而封装过后的 map 映射有了更易读的意义,映射关系(输入、输出)也是函数式编程之核心!


YY一下:既然 map 这类函数都是从 for 循环封装来的,如果你能封装一个基于 for 循环的另一种特别实用的高阶映射或者其它高阶函数,是不是意味着:有朝一日有可能被纳入 JS 版本标准 API 中?🐶🐶🐶


或许:先意识到我们每天都在使用的高阶函数,刻意的去使用、训练,然后能举一反三,才能做上面的想象吧~~~



链接:https://juejin.cn/post/7006077858338570270

收起阅读 »

用canvas实现一个大气球送给你

一、背景 近期在做一个气球挂件的特效需求,值此契机,来跟大家分享一下如何利用canvas以及对应的数学知识构造一个栩栩如生的气球。 二、实现 在实现这个看似是圆鼓鼓的气球之前,先了解一下其实现思路,主要分为以下几个部分: 实现球体部分; 实现气球口...
继续阅读 »

一、背景



近期在做一个气球挂件的特效需求,值此契机,来跟大家分享一下如何利用canvas以及对应的数学知识构造一个栩栩如生的气球。



balloon1.gif


二、实现



在实现这个看似是圆鼓鼓的气球之前,先了解一下其实现思路,主要分为以下几个部分:




  1. 实现球体部分;

  2. 实现气球口子部分;

  3. 实现气球的线部分;

  4. 进行颜色填充;

  5. 实现动画;


气球.PNG


2.1 球体部分实现



对于这样的气球的球体部分,大家都有什么好的实现思路的?相信大家肯定会有多种多样的实现方案,我也是在看到某位大佬的效果后,感受到了利用四个三次贝塞尔曲线实现这个效果的妙处。为了看懂后续代码,先了解一下三次贝塞尔曲线的原理。(注:引用了CSDN上某位大佬的文章,写的很好,下图引用于此)



三次贝塞尔曲线.gif



在上图中P0为起始点、P3为终止点,P1和P2为控制点,其最终的曲线公式如下所示:



B(t)=(1−t)^3 * P0+3t(1−t)^2 * P1+3t ^ 2(1−t) * P2+t ^ 3P3, t∈[0,1]



上述已经列出了三次贝塞尔曲线的效果图和公式,但是通过这个怎么跟我们的气球挂上钩呢?下面通过几张图就理解了:



image.png



如上图所示,就是实现整个气球球体的思路,具体解释如下所示:




  1. A图中起始点为p1,终止点为p2,控制点为c1、c2,让两个控制点重合,绘制出的效果并不是很像气球的一部分,此时就要通过改变控制点来改变其外观;

  2. 改变控制点c1、c2,c1中y值不变,减小x值;c2中x值不变,增大y值(注意canvas中坐标方向即可),改变后就得到了图B的效果,此时就跟气球外观很像了;

  3. 紧接着按照这个方法就可以实现整个的气球球体部分的外观。


function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.translate(250, 250);
drawCoordiante(ctx);
ctx.save();
ctx.beginPath();
ctx.moveTo(0, -80);
ctx.bezierCurveTo(45, -80, 80, -45, 80, 0);
ctx.bezierCurveTo(80, 85, 45, 120, 0, 120);
ctx.bezierCurveTo(-45, 120, -80, 85, -80, 0);
ctx.bezierCurveTo(-80, -45, -45, -80, 0, -80);
ctx.stroke();
ctx.restore();
}

function drawCoordiante(ctx) {
ctx.beginPath();
ctx.moveTo(-120, 0);
ctx.lineTo(120, 0);
ctx.moveTo(0, -120);
ctx.lineTo(0, 120);
ctx.closePath();
ctx.stroke();
}

2.2 口子部分实现



口子部分可以简化为一个三角形,效果如下所示:



image.png


function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

……

ctx.save();
ctx.beginPath();
ctx.moveTo(0, 120);
ctx.lineTo(-5, 130);
ctx.lineTo(5, 130);
ctx.closePath();
ctx.stroke();
ctx.restore();
}

2.3 线部分实现



线实现的比较简单,就用了一段直线实现



image.png


function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

……

ctx.save();
ctx.beginPath();
ctx.moveTo(0, 120);
ctx.lineTo(0, 300);
ctx.stroke();
ctx.restore();
}

2.4 进行填充



气球部分的填充用了圆形渐变效果,相比于纯色来说更加漂亮一些。



function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.fillStyle = getBalloonGradient(ctx, 0, 0, 80, 210);
……

}

function getBalloonGradient(ctx, x, y, r, hue) {
const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
grd.addColorStop(0, 'hsla(' + hue + ', 100%, 65%, .95)');
grd.addColorStop(0.4, 'hsla(' + hue + ', 100%, 45%, .85)');
grd.addColorStop(1, 'hsla(' + hue + ', 100%, 25%, .80)');
return grd;
}

image.png


2.5 动画效果及整体代码



上述流程已经将一个静态的气球部分绘制完毕了,要想实现动画效果只需要利用requestAnimationFrame函数不断循环调用即可实现。下面直接抛出整体代码,方便同学们观察效果进行调试,整体代码如下所示:



let posX = 225;
let posY = 300;
let points = getPoints();
draw();

function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (posY < -200) {
posY = 300;
posX += 300 * (Math.random() - 0.5);
points = getPoints();
}
else {
posY -= 2;
}
ctx.save();
ctx.translate(posX, posY);
drawBalloon(ctx, points);
ctx.restore();

window.requestAnimationFrame(draw);
}

function drawBalloon(ctx, points) {
ctx.scale(points.scale, points.scale);
ctx.save();
ctx.fillStyle = getBalloonGradient(ctx, 0, 0, points.R, points.hue);
// 绘制球体部分
ctx.moveTo(points.p1.x, points.p1.y);
ctx.bezierCurveTo(points.pC1to2A.x, points.pC1to2A.y, points.pC1to2B.x, points.pC1to2B.y, points.p2.x, points.p2.y);
ctx.bezierCurveTo(points.pC2to3A.x, points.pC2to3A.y, points.pC2to3B.x, points.pC2to3B.y, points.p3.x, points.p3.y);
ctx.bezierCurveTo(points.pC3to4A.x, points.pC3to4A.y, points.pC3to4B.x, points.pC3to4B.y, points.p4.x, points.p4.y);
ctx.bezierCurveTo(points.pC4to1A.x, points.pC4to1A.y, points.pC4to1B.x, points.pC4to1B.y, points.p1.x, points.p1.y);

// 绘制气球钮部分
ctx.moveTo(points.p3.x, points.p3.y);
ctx.lineTo(points.knowA.x, points.knowA.y);
ctx.lineTo(points.knowB.x, points.knowB.y);
ctx.fill();
ctx.restore();

// 绘制线部分
ctx.save();
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(points.p3.x, points.p3.y);
ctx.lineTo(points.lineEnd.x, points.lineEnd.y);
ctx.stroke();
ctx.restore();
}

function getPoints() {
const offset = 35;
return {
scale: 0.3 + Math.random() / 2,
hue: Math.random() * 255,
R: 80,
p1: {
x: 0,
y: -80
},
pC1to2A: {
x: 80 - offset,
y: -80
},
pC1to2B: {
x: 80,
y: -80 + offset
},
p2: {
x: 80,
y: 0
},
pC2to3A: {
x: 80,
y: 120 - offset
},
pC2to3B: {
x: 80 - offset,
y: 120
},
p3: {
x: 0,
y: 120
},
pC3to4A: {
x: -80 + offset,
y: 120
},
pC3to4B: {
x: -80,
y: 120 - offset
},
p4: {
x: -80,
y: 0
},
pC4to1A: {
x: -80,
y: -80 + offset
},
pC4to1B: {
x: -80 + offset,
y: -80
},
knowA: {
x: -5,
y: 130
},
knowB: {
x: 5,
y: 130
},
lineEnd: {
x: 0,
y: 250
}
};
}

function getBalloonGradient(ctx, x, y, r, hue) {
const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
grd.addColorStop(0, 'hsla(' + hue + ', 100%, 65%, .95)');
grd.addColorStop(0.4, 'hsla(' + hue + ', 100%, 45%, .85)');
grd.addColorStop(1, 'hsla(' + hue + ', 100%, 25%, .80)');
return grd;
}


链接:https://juejin.cn/post/7006967510134161438

收起阅读 »

通过一个例子学习css层叠上下文

层叠上下文 & 层叠等级 & 层叠规则 http://www.w3.org/TR/CSS22/vi… The order in which the rendering tree is painted onto the canvas is d...
继续阅读 »

层叠上下文 & 层叠等级 & 层叠规则



http://www.w3.org/TR/CSS22/vi…


The order in which the rendering tree is painted onto the canvas is described in terms of stacking contexts. Stacking contexts can contain further stacking contexts. A stacking context is atomic from the point of view of its parent stacking context; boxes in other stacking contexts may not come between any of its boxes.


Each box belongs to one stacking context. Each positioned box in a given stacking context has an integer stack level, which is its position on the z-axis relative other stack levels within the same stacking context. Boxes with greater stack levels are always formatted in front of boxes with lower stack levels. Boxes may have negative stack levels. Boxes with the same stack level in a stacking context are stacked back-to-front according to document tree order.


The root element forms the root stacking context.



翻译一下:
渲染树被绘制到画布上的顺序是根据层叠上下文来描述的。层叠上下文可以包含更多的层叠上下文。从父层叠上下文的角度来看,层叠上下文是原子的;其他层叠上下文中的盒子可能不会出现在它的任何盒子中。


每个框都属于一个层叠上下文。给定层叠上下文中的每个定位框都有一个整数层叠等级,这是它在 z 轴上相对于同一层叠上下文中其他层叠等级的位置。具有较高层叠等级的框始终放置在具有较低层叠等级的框之前。盒子可能有负的层叠等级。层叠上下文中具有相同层叠等级的框根据文档树顺序从后到前绘制。


根元素创建根层叠上下文。


理解:
所有的元素都属于一个层叠上下文,所以所有的元素都有自己的层叠等级。
每个元素都有自己所属的层叠上下文,在当前层叠上下文中具有自己的层叠等级。



那层叠等级的规则是啥呢?



http://www.w3.org/TR/CSS22/vi…


Within each stacking context, the following layers are painted in back-to-front order:


the background and borders of the element forming the stacking context.
the child stacking contexts with negative stack levels (most negative first).
the in-flow, non-inline-level, non-positioned descendants.
the non-positioned floats.
the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
the child stacking contexts with stack level 0 and the positioned descendants with stack level 0.
the child stacking contexts with positive stack levels (least positive first).



在每一个层叠上下文中,阿照下面的顺序从后往前绘制。



  1. 创建层叠上下文元素的背景和边框

  2. 创建层叠上下文元素的具有负层叠等级子元素

  3. 非inline元素并且没有定位的后代【block后代】

  4. 非定位的浮动元素

  5. 包括inline-table / inline-block的非定位inline元素

  6. 创建层叠上下文元素的层叠等级为0的子元素【0 / auto】

  7. 创建层叠上下文元素的层叠等级为大于0的子元素



关于这个等级张鑫旭有一张图说明
image.png
这里提到了一个新增的:不依赖于z-index的层叠上下文,这里指的应该是css3会有一些元素在不通过定位来创建新的层叠上下文



  1. z-index值不为auto的flex项(父元素display:flex|inline-flex).

  2. 元素的opacity值不是1.

  3. 元素的transform值不是none.

  4. 元素mix-blend-mode值不是normal.

  5. 元素的filter值不是none.

  6. 元素的isolation值是isolate.

  7. will-change指定的属性值为上面任意一个。

  8. 元素的-webkit-overflow-scrolling设为touch.





Demo



先看parent元素


<style>
.parent {
width: 100px;
height: 200px;
background: #168bf5;
position: absolute;
top: 0;
left: 0;
z-index: 0;
}
.child1 {
width: 100px;
height: 200px;
background: #32d19c;
position: absolute;
top: 20px;
left: 20px;
z-index: 1;
}
.child1-2 {
width: 100px;
height: 200px;
background: #7131c1;
}
.child1-1 {
width: 100px;
height: 200px;
background: #808080;
float: left;
}
.child2 {
width: 100px;
height: 200px;
background: #e4c950;
position: absolute;
top: 40px;
left: 40px;
z-index: -1;
}
</style>
</head>

<body>
<div class="parent">
parent
<div class="child1">child1
<div class="child1-2">child1-2</div>
<div class="child1-1">child1-1</div>
<!-- <div>child1-1</div>
<div>child1-2</div> -->
</div>
<div class="child2">
child2
</div>
</div>
</body>

image.png


先从根节点看起:根节点上根层级上下文,因为只有一个子节点parent。然后parent有自己的层叠上下文。parent有两个子节点,上文说到每个盒子属于一个层叠上下文,parent属于html的层叠上下文,parent会创建自己的层叠上下文,当然这个层叠上下文的作用主要针对parent的子元素。child1,child2。


image.pngimage.png


因为child1的z-index为1,child2的z-index为-1。所以这里的child1会绘制在child2的上面。


当我们在看child1的子元素和child2的子元素就不能放在一起看了,因为child1和child2都创建了自己的层叠上下文。只能独立看了。


这里child2的绘制会在parent的上面,尽管child2的z-index为负树。这里也对应了上面说的7层关系。因为parent属于创建层叠上下文的元素。



知识点:层叠上下文



  1. 普通元素的层叠等级优先由其所在的层叠上下文决定。

  2. 层叠等级的比较只有在当前层叠上下文元素中才有意义。不同层叠上下文中比较层叠等级是没有意义的。





知识点:层叠等级



  1. 在同一个层叠上下文中,它描述定义的是该层叠上下文中的层叠上下文元素在Z轴上的上下顺序。

  2. 在其他普通元素中,它描述定义的是这些普通元素在Z轴上的上下顺序。





接下来看block层级小于float


image.png


再看具体的页面渲染,我们修改一下代码,将child1-2和child1-2的顺序调换一下:


image.pngimage.png


这里不同的顺序会有不同的效果:第二张图看得出来是我们期望的,child1-2绘制到了chil1-1下面。因为float元素没有脱离文本流,所以child1-2的文本会被挤压到下面去。那么我们看一下第一张图为什么会这样。
从float的概念当中就可以看出来了。
浮动定位作用的是当前行,当前浮动元素在绘制的时候,child1父元素第一个元素是block元素,所以。float在绘制的时候,因为child1-1的宽度和child1的宽度相同,所以float所在的当前行就是目前的位置。第二张图是我们期望的结果是因为float在绘制的时候所在的当前行就是第一行。所以会按照我们期望的体现。



接下来看float小于inline / inline-block


我们接着上面第二张图继续看。这样是看不出来效果的,需要修改一下代码再看。


<style>
.parent {
width: 100px;
height: 200px;
background: #168bf5;
position: absolute;
top: 0;
left: 0;
z-index: 0;
}
.child1 {
width: 200px;
height: 200px;
background: #32d19c;
position: absolute;
top: 20px;
left: 20px;
z-index: 1;
}
.child1-2 {
width: 100px;
height: 200px;
background: #7131c1;
display: inline-block;
}
.child1-1 {
width: 100px;
height: 200px;
background: #808080;
margin: 10px -15px 10px 10px;
float: left;
}
.child2 {
width: 100px;
height: 200px;
background: #e4c950;
position: absolute;
top: 40px;
left: 40px;
z-index: -1;
}
</style>
</head>

<body>
<div>
parent
<div>child1
<divhljs-number">1">child1-1</div>
<divhljs-number">2">child1-2</div>
</div>
<div>
child2
</div>
</div>
</body>

image.png
修改代码是需要将float元素和inline-block元素放在同一行,如果不是在同一行是没意义的。我们可以看到child1的文本节点和child1-2的inline-block元素都绘制在了child1-1的元素上面了。


论证一下css3的内容


也就是下面这个红框的内容:


image.png
继续用上面的例子:
上面看到的float元素已经放置在了inline / inline-block内容的下面。现在我们加一下:上面说的css3的样式在看一下。下面的两个例子可以看到之前放置在inline / inline-block下面的child1-1已经绘制在上面了。



opacity


image.png



tranform


image.png





概念



z-index



  1. 首先,z-index属性值并不是在任何元素上都有效果。它仅在定位元素(定义了position属性,且属性值为非static值的元素)上有效果。

  2. 判断元素在Z轴上的堆叠顺序,不仅仅是直接比较两个元素的z-index值的大小,这个堆叠顺序实际由元素的层叠上下文层叠等级共同决定。





层叠上下文的特性



  • 层叠上下文的层叠水平要比普通元素高;

  • 层叠上下文可以嵌套,内部层叠上下文及其所有子元素均受制于外部的层叠上下文。

  • 每个层叠上下文和兄弟元素独立,也就是当进行层叠变化或渲染的时候,只需要考虑后代元素。

  • 每个层叠上下文是自成体系的,当元素发生层叠的时候,整个元素被认为是在父层叠上下文的层叠顺序中



链接:https://juejin.cn/post/7006978541988347941

收起阅读 »

【中秋】纯CSS实现日地月的公转

我们都知道中秋的月亮又大又圆,是因为太阳地球月亮在公转过程中处在了一条直线上,地球在中间,太阳和月球分别在地球的两端,这天的月相便是满月。这段可以略过,是为了跟中秋扯上关系。 但因为我根本没咋学过前端,这两天恶补了一下重学了 flexbox 和 grid ,成...
继续阅读 »

我们都知道中秋的月亮又大又圆,是因为太阳地球月亮在公转过程中处在了一条直线上,地球在中间,太阳和月球分别在地球的两端,这天的月相便是满月。这段可以略过,是为了跟中秋扯上关系。


但因为我根本没咋学过前端,这两天恶补了一下重学了 flexboxgrid ,成果应该说还挺好看(如果我的审美没有问题的话)。


配色我挺喜欢的,希望你也喜欢。


源码我放到了 CodePen 上,链接 Sun Earth Moon (codepen.io)


HTML


重点是CSS,HTML放上三个 div 就🆗了。


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Mancuoj</title>
<link
href="simulation.css"
rel="stylesheet"
/>
</head>

<body>
<h1>Mancuoj</h1>
<figure class="container">
<div class="sun"></div>
<div class="earth">
<div class="moon"></div>
</div>
</figure>
</body>
</html>

背景和文字


导入我最喜欢的 Lobster 字体,然后设为白色,字体细一点。


@import url("https://fonts.googleapis.com/css2?family=Lobster&display=swap");

h1 {
color: white;
font-size: 60px;
font-family: Lobster, monospace;
font-weight: 100;
}

背景随便找了一个偏黑紫色,然后把画的内容设置到中间。


body {
margin: 0;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #2f3141;
}

.container {
font-size: 10px;
width: 40em;
height: 40em;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}

日地月动画


众所周知:地球绕着太阳转,月球绕着地球转。


我们画的是公转,太阳就直接画出来再加个阴影高光,月亮地球转就可以了。


最重要的其实是配色(文章末尾有推荐网站),我实验好长时间的配色,最终用了三个渐变色来表示日地月。


日: linear-gradient(#fcd670, #f2784b);
地: linear-gradient(#19b5fe, #7befb2);
月: linear-gradient(#8d6e63, #ffe0b2);

CSS 应该难不到大家,随便看看吧。


轨道用到了 border,用银色线条当作公转的轨迹。


动画用到了自带的 animation ,每次旋转一周。


.sun {
position: absolute;
width: 10em;
height: 10em;
background: linear-gradient(#fcd670, #f2784b);
border-radius: 50%;
box-shadow: 0 0 8px 8px rgba(242, 120, 75, 0.2);
}

.earth {
--diameter: 30;
--duration: 36.5;
}

.moon {
--diameter: 8;
--duration: 2.7;
top: 0.3em;
right: 0.3em;
}

.earth,
.moon {
position: absolute;
width: calc(var(--diameter) * 1em);
height: calc(var(--diameter) * 1em);
border-width: 0.1em;
border-style: solid solid none none;
border-color: silver transparent transparent transparent;
border-radius: 50%;
animation: orbit linear infinite;
animation-duration: calc(var(--duration) * 1s);
}

@keyframes orbit {
to {
transform: rotate(1turn);
}
}

.earth::before {
--diameter: 3;
--color: linear-gradient(#19b5fe, #7befb2);
--top: 2.8;
--right: 2.8;
}

.moon::before {
--diameter: 1.2;
--color: linear-gradient(#8d6e63, #ffe0b2);
--top: 0.8;
--right: 0.2;
}

.earth::before,
.moon::before {
content: "";
position: absolute;
width: calc(var(--diameter) * 1em);
height: calc(var(--diameter) * 1em);
background: var(--color);
border-radius: 50%;
top: calc(var(--top) * 1em);
right: calc(var(--right) * 1em);
}

总结


参加个活动真不容易,不过前端还是挺好玩的。


链接:https://juejin.cn/post/7006507905050492935

收起阅读 »

Bitmap和Drawable

Bitmap:图片信息的存储工具,保存每一个像素是什么颜色image: width:640 height:400 pixel:ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000f...
继续阅读 »
  • Bitmap:图片信息的存储工具,保存每一个像素是什么颜色

image: width:640 height:400 pixel:ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000

  • Drawable是什么(Drawable在代码上是接口,BitmapDrawable、ColorDrawable等实现)?

drawable是绘制工具,重写draw进行绘制

  • view和drawable?
  1. 都是使用canvas进行绘制。
  2. drawable纯绘制工具。
  3. view会包含测量、布局、绘制。
  4. drawable绘制的时候一定要设置边界setBounds
class DrawableViewcontextContextattrAttributeSet):Viewcontextattr){
private val drawable = ColorDrawable(Color.RED)
override fun onDraw(canvas:Canvas){
super.onDraw(canvas)
drawable.setBounds(0,0,width,height)
drawable.draw(canvas)
}
}
  • Bitmap和Drawable怎么互转?(其实不是互转,是使用一个实例创建了另外一个实例)
  1. Bitmap转Drawable

java

Drawable d = new BitmapDrawable(getResource(),bitmap);

kotlin(ktx)

bitmap.toDrawable(resource)
  1. Drawable转Bitmap

java

public static Bitmap drawableToBitmap(Drawable drawable){
Bitmap bitmap = null;
if(drawable instance BitmapDrawable){//1、如果是BitmapDrawable
BitmapDrawable bitmapDrawable = (BitmapDrawable)drawable;
if(bitmapDrawable.getBitmap()!=null){
return bitmapDrawable.getBitmap();
}
}
//2、如果drawable的宽高小于等于0
if(drawable.getIntrinsicWidth()<=0||drawable.getIntrinsicHeight()<=0){
bitmap = Bitmap.createBitmap(1,1,Bitmap.ARGB_8888);
}else{
bitmap =Bitmap.createBitmap(drawable.getIntrinsicWidth(),drawable.getIntrinsicHeight(),
Bitmap.Config.ARGB_8888)
}
Canvas canvas = new Canvas(bitmap);
drawable.setBounds(0,0,canvas.getWidth(),canvas.getHeight());
drawable.draw(canvas);
return bitmap;
}

kotlin(ktx)

drawable.toBitmap()
  • 自定义Drawable(作用:一个自定义drawable可以把多个view中的重复代码提出来,在多个view之间进行复用)
//画一个网眼
private val INTERVAL = 50.dp
class MeshDrawable:Drawable(){
private val paint = Paint(Paint.ANT_ALIAS_FLAG)
override fun draw(canvas:Canvas){
var x = bounds.lef.toFloat()
while(x<=bounds.right.toFloat()){
canvas.drawLine(x,bound.top.toFloat(),
x,bounds.bottom.toFloat(),paint)
x+=INTERVAL
}
var y = bounds.top.toFloat()
while(y<=bounds.bottom.toFloat()){
canvas.drawLine(bounds.left.toFloat(),y,
bounds.right.toFloat(),y,paint)
y+=INTERVAL
}
}
override fun setAlpha(alpha:Int){
paint.alpha = alpha
}
override fun getAlpha():Int{
return paint.alpha
}
override fun getOpacity():Int{//不透明度
return when(paint.alpha){
0->PixelFormat.TRANSPARENT
0xff ->PixelFormat.OPAQUE
else ->PixelFormat.TRANSLUCENT
}
}
override fun setColorFilter(colorFilter:ColorFilter?){
paint.colorFilter = colorFilter
}
override fun getColorFilter():ColorFilter{
return paint.colorFilter
}
}

getWidth() 是实际显示的宽度。

getMeasureWidth() 是测量宽度,在布局之前计算出来的。

getIntrinsicWidth() 是原有宽度,有时候原有宽度可能很大,但是实际上空间不够,所有效果上并没有那么大,这个方法可以获得原有宽度,可以辅助测量的时候选择合适的展示宽度。

getMinimumWidth() 是最小宽度,是XML参数定义里的 minWidth,也是一个辅助测量展示的参数。

收起阅读 »

Android高德地图踩坑记录-内存泄漏问题

1、问题现象最近做项目优化,在查找app可能存在的内存泄漏地方,项目中有用到高德地图SDK,有一个页面有展示地图,每次退出该页面的时候,LeakCanary老是提示有内存泄漏,泄漏的大概信息如下:2、排查问题看样子像是高德地图相关的内存泄漏,不过为了进一步可以...
继续阅读 »

1、问题现象

最近做项目优化,在查找app可能存在的内存泄漏地方,项目中有用到高德地图SDK,有一个页面有展示地图,每次退出该页面的时候,LeakCanary老是提示有内存泄漏,泄漏的大概信息如下:

image.png

2、排查问题

看样子像是高德地图相关的内存泄漏,不过为了进一步可以定位到问题,通常可以采用一种虽然有些笨但是可以定位到问题点的方法:控制变量法,排除到不太可能出现问题的地方,只保留可能出现的问题,具体是先注释掉和高德地图无关的代码,然后复现问题,确保问题是出在和高德地图相关的代码上

经过一系列的注释代码然后复现操作,明确内存泄漏的点是在高德地图相关的操作上,通过分析LeakCanary生成的Heap Dump(堆转储)文件,也验证了这个猜想

image.png

我在代码里有封装过一个关于地图操作的utils类,刚开始以为是在页面销毁的时候,这个utils类里有一些资源没有释放,比如当前Activity的context引用,在改为Application引用之后,发现问题还是有,然后在Activity销毁的时候,对utils里的一些资源进行了释放,发现还是不可以

后来经过在网上查找资料,查看高德地图官方demo,发现一个细节有可能是使用Butterknife的问题

image.png

因为在onDestroy方法里,我有写MapView的销毁方法,但是没有进入到if语句里面

image.png

3、问题解决方式

不使用ButterKnife的方式获取MapView控件,采用原生的findViewById的方式来获取控件对象

image.png

image.png

经过反复测试,退出页面之后,LeakCanary没有报内存泄漏的吐司

4、总结

使用高德地图SDK,地图控件MapView,使用原生的findViewById的方式来获取

收起阅读 »

如何打造一款权限请求框架

原理通过向当前Activity添加一个不可见的Fragment,从而实现权限申请流程的封装。实现不可见的Fragmentinternal class EPermissionFragment : Fragment() { private var mCal...
继续阅读 »

原理

通过向当前Activity添加一个不可见的Fragment,从而实现权限申请流程的封装。

实现

不可见的Fragment

internal class EPermissionFragment : Fragment() {
private var mCallback: EPermissionCallback? = null

fun requestPermission(callback: EPermissionCallback, vararg permissions: String) {
mCallback = callback
// 申请权限
requestPermissions(permissions, CODE_REQUEST_PERMISSION)
}

override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == CODE_REQUEST_PERMISSION) {
val deniedList = ArrayList<String>()
val deniedForeverList = ArrayList<String>()
grantResults.forEachIndexed { index, result ->
// 提取权限申请结果
if (result != PackageManager.PERMISSION_GRANTED) {
val permission = permissions[index]
deniedList.add(permission)
// 是否拒绝且不再显示
if (!shouldShowRequestPermissionRationale(permission)) {
deniedForeverList.add(permission)
}
}
}
if (deniedList.isEmpty()) mCallback?.onAllGranted()
if (deniedList.isNotEmpty()) mCallback?.onDenied(deniedList)
if (deniedForeverList.isNotEmpty()) mCallback?.onDeniedForever(deniedForeverList)
}
}

override fun onDestroy() {
mCallback = null
super.onDestroy()
}
}

封装权限申请

// 扩展FragmentActivity
fun FragmentActivity.runWithPermissions(
vararg permissions: String,
onDenied: (ArrayList<String>) -> Unit = { _ -> },
onDeniedForever: (ArrayList<String>) -> Unit = { _ -> },
onAllGranted: () -> Unit = {}
) {
if (checkPermissions(*permissions)) {
onAllGranted()
return
}
// 添加一个不可见的Fragment
val isFragmentExist = supportFragmentManager.findFragmentByTag(EPermissionFragment.TAG)
val fragment = if (isFragmentExist != null) {
isFragmentExist as EPermissionFragment
} else {
val invisibleFragment = EPermissionFragment()
supportFragmentManager.beginTransaction().add(invisibleFragment, EPermissionFragment.TAG).commitNowAllowingStateLoss()
invisibleFragment
}
val callback = object : EPermissionCallback {
override fun onAllGranted() {
onAllGranted()
}

override fun onDenied(deniedList: ArrayList<String>) {
onDenied(deniedList)
}

override fun onDeniedForever(deniedForeverList: ArrayList<String>) {
onDeniedForever(deniedForeverList)
}
}
// 申请权限
fragment.requestPermission(callback, *permissions)
}

使用方法

项目build.gradle添加

allprojects {
repositories {
...
maven { url 'https://www.jitpack.io' }
}
}

模块build.gradle添加

dependencies {
implementation 'com.github.RickyHal:EPermission:$latest_version'
}

在Activity或者Fragment中直接调用

// 申请存储权限
runWithPermissions(
*EPermissions.STORAGE,
onDenied = {
Toast.makeText(this, "STORAGE permission denied", Toast.LENGTH_SHORT).show()
},
onDeniedForever = {
Toast.makeText(this, "STORAGE permission denied forever", Toast.LENGTH_SHORT).show()
},
onAllGranted = {
Toast.makeText(this, "STORAGE permission granted", Toast.LENGTH_SHORT).show()
}
)

也可以用下面这个简单的方法

runWithStoragePermission(onFailed = {
Toast.makeText(this, "SMS permission denied", Toast.LENGTH_SHORT).show()
}) {
Toast.makeText(this, "SMS permission granted", Toast.LENGTH_SHORT).show()
}

一次申请多个权限

runWithPermissions(*EPermissions.CAMERA, *EPermissions.STORAGE,
onDenied = { deniedList ->
Toast.makeText(this, "permission denied $deniedList", Toast.LENGTH_SHORT).show()
},
onDeniedForever = { deniedForeverList ->
Toast.makeText(this, "permission denied forever $deniedForeverList", Toast.LENGTH_SHORT).show()
},
onAllGranted = {
Toast.makeText(this, "Permission all granted", Toast.LENGTH_SHORT).show()
})

如果不需要处理失申请权限败的情况,也可以直接这样写

runWithStoragePermission {
Toast.makeText(this, "SMS permission granted", Toast.LENGTH_SHORT).show()
}

如果某些操作执行的时候,只能有权限才去执行,则可以使用下面的方法

doWhenPermissionGranted(*EPermissions.CAMERA){
Toast.makeText(this, "Do this when camera Permission is granted", Toast.LENGTH_SHORT).show()
}

检查权限

if (checkPermissions(*EPermissions.CAMERA)) {
Toast.makeText(this, "Camera Permission is granted", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "Camera Permission is not granted", Toast.LENGTH_SHORT).show()
}

收起阅读 »

Dart 2.14 发布,新增语言特性和共享标准 lint

Dart 2.14 的发布对 Apple Silicon 处理器提供了更好的支持,并新增了更多提升生产力的功能,例如通过代码样式分析捕获 lint 错误、更快的发布工具、更好的级联代码格式以及一些细小的语言特性更新。 Dart SDK 对 Apple Sili...
继续阅读 »

Dart 2.14 的发布对 Apple Silicon 处理器提供了更好的支持,并新增了更多提升生产力的功能,例如通过代码样式分析捕获 lint 错误、更快的发布工具、更好的级联代码格式以及一些细小的语言特性更新。


Dart SDK 对 Apple Silicon 支持


自从在 2020 年末 Apple 发布了新的 Apple Silicon 处理器以来, Dart SDK 一直致力于增加对该处理器上的 Native 执行支持。


现在从 Dart 2.14.1 正式增加了对 Apple Silicon 的支持,当 下载 MacOS 的 Dart SDK时,一定要选择 ARM64 选项,这里需要额外注意, Flutter SDK 中的 Dart SDK 还没有绑定这一项改进


本次更新支持在 Apple Silicon 上运行 SDK/Dart VM 本身,以及对 dart compile 编译后的可执行文件在 Apple Silicon 上运行的支持,由于 Dart 命令行工具使用原生 Apple Silicon ,因此它们的启动速度会快得多


Dart 和 Flutter 共享的标准 lint


开发人员通常会需要他们的代码遵循某种风格,其中许多规则不仅仅是风格偏好(如众所周知的制表符与空格的问题),还涵盖了可能导致错误或引入错误的编码风格。


比如 Dart 风格指南要求对所有控制流结构使用花括号,例如 if-else 语句,这可以防止经典的 dangling else 问题,也就是在多个嵌套的 if-else 语句上会存在解释歧义。



另一个例子是类型推断,虽然在声明具有初始值的变量时使用类型推断没有问题,但声明未初始化的变量 时指定类型很重要,因为这可以确保类型安全



良好代码风格的通常是通过代码审查来维持,但是通过在编写代码时,运行静态分析来强制执行规则通常会更有效得多。


在 Dart 中,这种静态分析规则是高度可配置的,Dart 提供了有数百条样式规则(也称为lints),有了如此丰富的选项,选择启用这些的规则时,一开始可能会有些不知所措。



配置支持: dart.dev/guides/lang…


lint 规则: dart.dev/tools/linte…



Dart 团队维护了一个 Dart 风格指南,它描述了 Dart 团队认为编写和设计 Dart 代码的最佳方式。



风格指南: dart.dev/guides/lang…



许多开发人员以及 pub.dev 站点评分引擎都使用了一套叫 Pedantic 的 lint 规则, Pedantic 起源于 Google 内部的 Dart 风格指南,由于历史原因它不同于一般的 Dart 风格指南,此外 Flutter 框架也从未使用过 Pedantic 的规则集,而是拥有自己的一套规范规则。


这听起来可能有点混乱,但是在本次的 2.14 发布中,Dart 团队很高兴地宣布现在拥有一套全新的 lint 集合来实现代码样式指南,并且 Dart 和 Flutter SDK 默认情况下将这些规则集用于新项目:




  • package:lints/core.yaml所有 Dart 代码都应遵循的 Dart 风格指南中的主要规则,pub.dev 评分引擎已更新为 lints/core 而不是 Pedantic。




  • package:lints/recommended.yaml :核心规则之外加上推荐规则,建议将它用于所有通用 Dart 代码。




  • package:flutter_lints/flutter.yaml:核心和推荐之外的 Flutter 特定推荐规则,这个集合推荐用于所有 Flutter 代码。




如果你已经存在现有的 Dart 或者 Flutter项目,强烈建议升级到这些新规则集,从 pedantic 升级只需几步:github.com/dart-lang/l…


Dart 格式化程序和级联


Dart 2.14 对 Dart 格式化程序如何使用级联 格式化代码进行了一些优化。


以前格式化程序在某些情况下出现一些令人困惑的格式,例如 doIt() 在这个例子中调用了什么?


var result = errorState ? foo : bad..doIt();

它看起来像是被 bad 调用 ,但实际上级联适是用于整个 ? 表达式上的,因此级联是在该表达式的结果上调用的,而不仅仅是在 false 子句上,新的格式化程序清晰地描述了这一点:


 var result = errorState ? foo : bad\
..doIt();

Dart 团队还大大提高了格式化包含级联的代码的速度;在协议缓冲区生成的 Dart 代码中,可以看到格式化速度提高了 10 倍。


Pub 支持忽略文件


目前当开发者将包发布pub.dev社区时,pub 会抓取该文件夹中的所有文件,但是会跳过隐藏文件(以 . 开头的文件)和.gitignore 文件。


Dart 2.14 中更新的 pub 命令支持新 .pubignore 文件,开发者可以在其中列出不想上传到 pub.dev 的文件,此文件使用与 .gitignore 文件相同的格式。



有关详细信息,请参阅包发布文档 dart.dev/tools/pub/p…



Pub and "dart test" 性能


虽然 pub 最常用于管理代码依赖项,但它还有第二个重要的用途:驱动工具。


比如 Dart 测试工具通过 dart test 命令运行,而它实际上只是 command pub run test:test 命令的包装, package:test 在调用该 test 入口点之前,pub 首先将其编译为可以更快运行的本机代码。


在 Dart 2.14 之前对 pubspec 的任何更改(包括与 package:test 无关的更改)都会使此测试构建无效,并且还会看到一堆这样的输出,其中包含“预编译可执行文件”:


$ dart test\
Precompiling executable... (11.6s)\
Precompiled test:test.\
00:01 +1: All tests passed!

在 Dart 2.14 中,pub 在构建步骤方面更加智能,让构建仅在版本更改时发生,此外还使用并行化改进了执行构建步骤的方式,因此可以完成得更快。


新的语言功能


Dart 2.14 还包含一些语言特性变化。


首先添加了一个新的 三重移位 运算符 ( >>>),这类似于现有的移位运算符 ( >>),但 >> 执行算术移位,>>> 执行逻辑或无符号移位,其中零位移入最高有效位,而不管被移位的数字是正数还是负数。


此次还删除了对类型参数的旧限制,该限制不允许使用泛型函数类型作为类型参数,以下所有内容在 2.14 之前都是无效的,但现在是允许的:


late List<T Function<T>(T)> idFunctions;
var callback = [<T>(T value) => value];
late S Function<S extends T Function<T>(T)>(S) f;

最后对注释类型进行了小幅调整,(诸如 @Deprecated 在 Dart 代码中常用来捕获元数据的注解)以前注解不能传递类型参数,因此 @TypeHelper<int>(42, "The meaning") 不允许使用诸如此类的代码,而现在此限制现已取消。


包和核心库更改


对核心 Dart 包和库进行了许多增强修改,包括:




  • dart:core: 添加了静态方法 hashhashAllhashAllUnordered




  • dart:coreDateTime 类现在可以更好地处理本地时间。




  • package:ffi:添加了对使用 arena 分配器管理内存的支持(示例)。Arenas 是一种基于区域的内存管理形式,一旦退出 arena/region 就会自动释放资源。




  • package:ffigen:现在支持从 C 类型定义生成 Dart 类型定义。




重大变化


Dart 2.14 还包含一些重大更改,预计这些变化只会影响一些特定的用例。


#46545:取消对 ECMAScript5 的支持


所有浏览器都支持最新的 ECMAScript 版本,因此两年前 Dart 就宣布 计划弃用对 ECMAScript 5 (ES5) 的支持,这使 Dart 能够利用最新 ECMAScript 中的改进并生成更小的输出,在 Dart 2.14 中,这项工作已经完成,Dart Web 编译器不再支持 ES5。因此不再支持较旧的浏览器(例如 IE11)


#46100:弃用 stagehand、dartfmt 和 dart2native


在 2020 年 10 月的 Dart 2.10 博客文章中 宣布了将所有 Dart CLI 开发人员工具组合成一个单一的组合dart工具(类似于该flutter工具),而现在 Dart 2.14 弃用了 dartfmtdart2native 命令,并停止使用 stagehand ,这些工具在统一在 dart-tool 中都有等价的替代品。


#45451:弃用 VM Native 扩展


Dart SDK 已弃用 Dart VM 的 Native 扩展,这是从 Dart 代码调用 Native 代码的旧机制,Dart FFI(外来函数接口)是当前用于此用例的新机制,正在积极发展 以使其功能更加强大且易于使用。


作者:恋猫de小郭
链接:https://juejin.cn/post/7005770958141308935
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »