[譯] 使用 Swift 實現原型動畫

ALVIN君發表於2018-04-27

關於開發移動應用,我最喜歡作的事情之一就是讓設計師的創作活躍起來。我想成為 iOS 開發者的原因之一就是能夠利用 iPhone 的力量,創造出友好的使用者體驗。因此,當 s23NYC 的設計團隊帶著 SNKRS Pass 的動畫原型來到我面前時,我既興奮同時又非常害怕:

[譯] 使用 Swift 實現原型動畫

應該從哪裡開始呢?當看到一個複雜的動畫模型時,這可能是一個令人頭疼的問題。在這篇文章中,我們將分解一個動畫和原型迭代來開發一個可複用的動畫波浪檢視。


在 Playground 中的原型設計

在我們開始之前,我們需要建立一個環境,在這個環境中,我們可以迅速設計我們的動畫原型,而不必不斷地構建和執行我們所做的每一個細微的變化。幸運的是,蘋果給我們提供了 Swift Playground,這是一個很好的能夠快速草擬前端程式碼的理想場所,而無需使用完整的應用容器。

通過選單欄中選擇 File > New > Playground…,讓我們在 Xcode 建立一個新的 Playground。選擇單檢視 Playground 模板,裡面寫好了一個 live view 的模版程式碼。我們需要確保選擇 Assistant Editor,以便我們的程式碼能夠實時更新。

[譯] 使用 Swift 實現原型動畫

水波動畫

我們正在製作的這個動畫是 SNKRS Pass 體驗的最後部分之一,這是一種新的方式,可以在零售店預定最新和最熱門的耐克鞋。當使用者去拿他們的鞋子時,我們想給他們一張數字通行證,感覺就像一張金色的門票。背景動畫的目的是模仿立體物品的真實性。當使用者傾斜該裝置時,動畫會作出反應並四處移動,就像光線從裝置上反射出來一樣。

讓我們從簡單地建立一些同心圓開始:

final class AnimatedWaveView: UIView {
    
    public func makeWaves() {
        var i = 1
        let baseDiameter = 25
        var rect = CGRect(x: 0, y: 0, width: baseDiameter, height: baseDiameter)
        // Continue adding waves until the next wave would be outside of our frame
        while self.frame.contains(rect) {
            let waveLayer = buildWave(rect: rect)
            self.layer.addSublayer(waveLayer)
            i += 1
            // Increase size of rect with each new wave layer added
            rect = CGRect(x: 0, y: 0, width: baseDiameter * i, height: baseDiameter * i)
        }
    }
    
    private func buildWave(rect: CGRect) -> CAShapeLayer {
        let circlePath = UIBezierPath(ovalIn: rect)
        let waveLayer = CAShapeLayer()
        waveLayer.bounds = rect
        waveLayer.frame = rect
        waveLayer.position = self.center
        waveLayer.strokeColor = UIColor.black.cgColor
        waveLayer.fillColor = UIColor.clear.cgColor
        waveLayer.lineWidth = 2.0
        waveLayer.path = circlePath.cgPath
        waveLayer.strokeStart = 0
        waveLayer.strokeEnd = 1
        return waveLayer
    }
}
複製程式碼

[譯] 使用 Swift 實現原型動畫

這非常簡單。現在如何將同心圓不停地向外擴大呢?我們將使用 CAAnimation 和 Timer 不斷新增 CAShape,並讓它們動起來。這個動畫有兩個部分:縮放形狀的路徑和增加形狀的邊界。重要的是,通過縮放變換對邊界做動畫,使圓圈移動最終充滿螢幕。如果我們沒有執行邊界的動畫,圓圈將不斷擴大,但會保持其檢視的原點在檢視的中心(向右下角擴充套件)。因此,讓我們將這兩個動畫新增到一個動畫組,以便同時執行它們。記住,CAShape 和 CAAnimation 需要將 UIKit 的值轉換為它們的 CGPath 和 CGColor 計數器。否則,動畫就會悄無聲息地失敗!我們還將使用 CAAnimation 放入委託方法 animationDidStop 在動畫完成後從檢視中刪除形狀圖層。

final class AnimatedWaveView: UIView {
    
    private let baseRect = CGRect(x: 0, y: 0, width: 25, height: 25)
    
    public func makeWaves() {
        DispatchQueue.main.async {
            Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(self.addAnimatedWave), userInfo: nil, repeats: true)
        }
    }
    
    @objc private func addAnimatedWave() {
        let waveLayer = self.buildWave(rect: baseRect)
        self.layer.addSublayer(waveLayer)
        self.animateWave(waveLayer: waveLayer)
    }
    
    private func buildWave(rect: CGRect) -> CAShapeLayer {
        let circlePath = UIBezierPath(ovalIn: rect)
        let waveLayer = CAShapeLayer()
        waveLayer.bounds = rect
        waveLayer.frame = rect
        waveLayer.position = self.center
        waveLayer.strokeColor = UIColor.black.cgColor
        waveLayer.fillColor = UIColor.clear.cgColor
        waveLayer.lineWidth = 2.0
        waveLayer.path = circlePath.cgPath
        waveLayer.strokeStart = 0
        waveLayer.strokeEnd = 1
        return waveLayer
    }
    
    private let scaleFactor: CGFloat = 1.5
    
    private func animateWave(waveLayer: CAShapeLayer) {
        // 縮放動畫
        let finalRect = self.bounds.applying(CGAffineTransform(scaleX: scaleFactor, y: scaleFactor))
        let finalPath = UIBezierPath(ovalIn: finalRect)
        let animation = CABasicAnimation(keyPath: "path")
        animation.fromValue = waveLayer.path
        animation.toValue = finalPath.cgPath
        
        // 邊界動畫
        let posAnimation = CABasicAnimation(keyPath: "bounds")
        posAnimation.fromValue = waveLayer.bounds
        posAnimation.toValue = finalRect
        
        // 動畫組
        let scaleWave = CAAnimationGroup()
        scaleWave.animations = [animation, posAnimation]
        scaleWave.duration = 10
        scaleWave.setValue(waveLayer, forKey: "waveLayer")
        scaleWave.delegate = self
        scaleWave.isRemovedOnCompletion = true
        waveLayer.add(scaleWave, forKey: "scale_wave_animation")
    }
}

extension AnimatedWaveView: CAAnimationDelegate {
    
    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        if let waveLayer = anim.value(forKey: "waveLayer") as? CAShapeLayer {
            waveLayer.removeFromSuperlayer()
        }
    }
}
複製程式碼

[譯] 使用 Swift 實現原型動畫

接下來,我們將為自定義路徑更替圓形。為了生成自定義路徑,我們可以使用 PaintCode 來幫助生成程式碼。在這篇文章中,我們將使用一個星形的波紋路徑:

struct StarBuilder {
    static func buildStar() -> UIBezierPath {
        let starPath = UIBezierPath()
        starPath.move(to: CGPoint(x: 12.5, y: 0))
        starPath.addLine(to: CGPoint(x: 14.82, y: 5.37))
        starPath.addLine(to: CGPoint(x: 19.85, y: 2.39))
        starPath.addLine(to: CGPoint(x: 18.57, y: 8.09))
        starPath.addLine(to: CGPoint(x: 24.39, y: 8.64))
        starPath.addLine(to: CGPoint(x: 20, y: 12.5))
        starPath.addLine(to: CGPoint(x: 24.39, y: 16.36))
        starPath.addLine(to: CGPoint(x: 18.57, y: 16.91))
        starPath.addLine(to: CGPoint(x: 19.85, y: 22.61))
        starPath.addLine(to: CGPoint(x: 14.82, y: 19.63))
        starPath.addLine(to: CGPoint(x: 12.5, y: 25))
        starPath.addLine(to: CGPoint(x: 10.18, y: 19.63))
        starPath.addLine(to: CGPoint(x: 5.15, y: 22.61))
        starPath.addLine(to: CGPoint(x: 6.43, y: 16.91))
        starPath.addLine(to: CGPoint(x: 0.61, y: 16.36))
        starPath.addLine(to: CGPoint(x: 5, y: 12.5))
        starPath.addLine(to: CGPoint(x: 0.61, y: 8.64))
        starPath.addLine(to: CGPoint(x: 6.43, y: 8.09))
        starPath.addLine(to: CGPoint(x: 5.15, y: 2.39))
        starPath.addLine(to: CGPoint(x: 10.18, y: 5.37))
        starPath.close()
        return starPath
    }
}
複製程式碼

[譯] 使用 Swift 實現原型動畫

(不按比例)

使用自定義路徑的棘手之處在於,我們現在需要擴充套件這條路徑,而不是從 AnimatedWaveView 的邊界生成一個最終的圓路徑。因為我們希望這個檢視是可以重用的,所以我們需要計算基於最終目標 rect 的形狀的路徑和邊界的大小。我們可以根據路徑最終邊界與其最初邊界的比例來建立 CGAffineTransform。我們還將這個比例乘以 2.25 的比例因子,以便在完成之前路徑擴充套件大於檢視。我們還需要將形狀完全填充我們檢視的每個角落,而不是一旦到達檢視的大小就消失。讓我們在初始化期間構建初始路徑和最終路徑,並在檢視的框架發生改變時,更新最終路徑:

private let initialPath: UIBezierPath = StarBuilder.buildStar()
private var finalPath: UIBezierPath = StarBuilder.buildStar()

let scaleFactor: CGFloat = 2.25

override var frame: CGRect {
    didSet {
        self.finalPath = calculateFinalPath()
    }
}

override init(frame: CGRect) {
    super.init(frame: frame)
    self.finalPath = calculateFinalPath()
}

private func calculateFinalPath() -> UIBezierPath {
    let path = StarBuilder.buildStar()
    let scaleTransform = buildScaleTransform()
    path.apply(scaleTransform)
    return path
}

private func buildScaleTransform() -> CGAffineTransform {
    // Grab initial and final shape diameter
    let initialDiameter = self.initialPath.bounds.height
    let finalDiameter = self.frame.height
    // Calculate the factor by which to scale the shape.
    let transformScaleFactor = finalDiameter / initialDiameter * scaleFactor
    // Build the transform
    return CGAffineTransform(scaleX: transformScaleFactor, y: transformScaleFactor)
}
複製程式碼

在更新動畫組後,使用新的 finalPath 屬性、 initialPath 和內部的 buildWave() 方法,我們會得到一個更新的路徑動畫:

[譯] 使用 Swift 實現原型動畫

確保我們可以在不同的大小能重用水波動畫的最後一步是:重構定時器方法。而不是一直建立新的水波,我們可以一次性建立所有的波紋,同時用 CAAnimation 錯開時間來執行動畫。這可以通過 CAAnimation 組中設定 timeoffset 來實現。通過給每個動畫組一個稍微不同的 timeoffset,我們可以從不同的起點同時執行所有動畫。我們將用動畫的總持續時間除以螢幕上的波數來計算偏移量:

// 每波之間 7 個畫素點
fileprivate let waveIntervals: CGFloat = 7

// 當直徑為 667 時,定時比為 40 秒。
fileprivate let timingRatio: CFTimeInterval = 40.0 / 667.0

public func makeWaves() {
  
    // 獲得較大的寬度或高度值
    let diameter = self.bounds.width > self.bounds.height ? self.bounds.width : self.bounds.height

    // 計算半徑減去初始 rect 的寬度
    let radius = (diameter - baseRect.width) / 2

    // 把半徑除以每個波的長度
    let numberOfWaves = Int(radius / waveIntervals)

    // 持續時間需要根據直徑來進行更改,以便在任何檢視大小下動畫速度都是相同的。
    let animationDuration = timingRatio * Double(diameter)

    for i in 0 ..< numberOfWaves {
        let timeOffset = Double(i) * (animationDuration / Double(numberOfWaves))
        self.addAnimatedWave(timeOffset: timeOffset, duration: animationDuration)
    }
}

private func addAnimatedWave(timeOffset: CFTimeInterval, duration: CFTimeInterval) {
    let waveLayer = self.buildWave(rect: baseRect, path: initialPath.cgPath)
    self.layer.addSublayer(waveLayer)
    self.animateWave(waveLayer: waveLayer, duration: duration, offset: timeOffset)
}
複製程式碼

我們將 durationtimeOffset 作為引數傳給 animateWave() 方法。讓我們新增一個淡入動畫作為組合的一部分,讓動畫變得更加流暢:

private func animateWave(waveLayer: CAShapeLayer, duration: CFTimeInterval, offset: CFTimeInterval) {
    // 淡入動畫
    let fadeInAnimation = CABasicAnimation(keyPath: "opacity")
    fadeInAnimation.fromValue = 0
    fadeInAnimation.toValue = 0.9
    fadeInAnimation.duration = 0.5

    // 路徑動畫
    let pathAnimation = CABasicAnimation(keyPath: "path")
    pathAnimation.fromValue = waveLayer.path
    pathAnimation.toValue = finalPath.cgPath

    // 邊界動畫
    let boundsAnimation = CABasicAnimation(keyPath: "bounds")
    let scaleTransform = buildScaleTransform()
    boundsAnimation.fromValue = waveLayer.bounds
    boundsAnimation.toValue = waveLayer.bounds.applying(scaleTransform)

    // 動畫組合
    let scaleWave = CAAnimationGroup()
    scaleWave.animations = [fadeInAnimation, boundsAnimation, pathAnimation]
    scaleWave.duration = duration
    scaleWave.isRemovedOnCompletion = false
    scaleWave.repeatCount = Float.infinity
    scaleWave.fillMode = kCAFillModeForwards
    scaleWave.timeOffset = offset
    waveLayer.add(scaleWave, forKey: waveAnimationKey)
}
複製程式碼

現在,我們可以在呼叫 makewaves() 方法來同時繪製每個波形並新增動畫。讓我們來看看效果:

[譯] 使用 Swift 實現原型動畫

喔呼!我們現在有一個可複用的動畫波浪檢視!

新增漸變

下一步是通過新增一個漸變來改進我們的水波動畫。我們還希望漸變能隨裝置移動感測器一起變化,因此我們將建立一個漸變層並保持對它的引用。我將半透明的水波層放在漸變的上面,但最好的解決方案是將所有水波層邊加到一個父層裡,並將這個父層其設定為漸變層的遮罩。通過這種方法,父層會自己去繪製漸變,這看起來更有效:

[譯] 使用 Swift 實現原型動畫

private func buildWaves() -> [CAShapeLayer] {
        
    // 獲得較大的寬度或高度值
    let diameter = self.bounds.width > self.bounds.height ? self.bounds.width : self.bounds.height

    // 計算半徑減去初始 rect 的寬度
    let radius = (diameter - baseRect.width) / 2

    // 把半徑除以每個波的長度
    let numberOfWaves = Int(radius / waveIntervals)

    // 持續時間需要根據直徑來進行更改,以便在任何檢視大小下動畫速度都是相同的。
    let animationDuration = timingRatio * Double(diameter)

    var waves: [CAShapeLayer] = []
    for i in 0 ..< numberOfWaves {
        let timeOffset = Double(i) * (animationDuration / Double(numberOfWaves))
        let wave = self.buildAnimatedWave(timeOffset: timeOffset, duration: animationDuration)
        waves.append(wave)
    }

    return waves
}

public func makeWaves() {
    let waves = buildWaves()
    let maskLayer = CALayer()
    maskLayer.backgroundColor = UIColor.clear.cgColor
    waves.forEach { maskLayer.addSublayer($0) }
    self.addGradientLayer(withMask: maskLayer)
    self.setNeedsDisplay()
}

private func addGradientLayer(withMask maskLayer: CALayer) {
    let gradientLayer = CAGradientLayer()
    gradientLayer.colors = [UIColor.black.cgColor, UIColor.lightGray.cgColor, UIColor.white.cgColor]
    gradientLayer.mask = maskLayer
    gradientLayer.frame = self.frame
    gradientLayer.bounds = self.bounds
    self.layer.addSublayer(gradientLayer)
}

private func buildAnimatedWave(timeOffset: CFTimeInterval, duration: CFTimeInterval) -> CAShapeLayer {
    let waveLayer = self.buildWave(rect: baseRect, path: initialPath.cgPath)
    self.animateWave(waveLayer: waveLayer, duration: duration, offset: timeOffset)
    return waveLayer
}
複製程式碼

運動追蹤

下一步是要將漸變動畫化,使之與裝置運動跟蹤。我們想要創造一種全息效果,當你將它傾斜在手中時,它能模仿反射在檢視表面的光。為此,我們將新增一個圍繞檢視中心旋轉的漸變。我們將使用 CoreMotion 和 CMMotionManager 跟蹤加速度計的實時更新,並將此資料用於互動式動畫。如果你想深入瞭解 CoreMotion 所提供的內容,NSHipster 上有一篇很棒的關於 CMDeviceMotion 的文章。對於我們的 AnimatedWaveView,我們只需 CMDeviceMoving 中的 gravity 屬性(CMAcceleration),它將返回裝置的加速度。當使用者水平和垂直地傾斜裝置時,我們只需要跟蹤 X 和 Y 軸:

[譯] 使用 Swift 實現原型動畫

developer.apple.com/documentati…

X 和 Y 會是從 -1 到 +1 之間的值,以(0,0)為原點(裝置平放在桌子上,面朝上)。現在我們要如何使用這些資料?

起初,我嘗試使用 CAGradientLayer,並認為旋轉漸變後會產生這種閃光效果。我們可以根據 CMDeviceMotion 的 gravity 來更新它的 startPointendPoint。Cagradientlayer 是一個線性漸變,因此圍繞中心的旋轉 startPointendPoint 將有效地旋轉漸變。讓我們把 x 和 y 值從 gravity 轉換成我們用來旋轉漸變的程度值:

fileprivate let motionManager = CMMotionManager()

func trackMotion() {
    if motionManager.isDeviceMotionAvailable {
        // 設定動作回撥觸發的頻率(秒為單位)
        motionManager.deviceMotionUpdateInterval = 2.0 / 60.0
        let motionQueue = OperationQueue()
        motionManager.startDeviceMotionUpdates(to: motionQueue, withHandler: { [weak self] (data: CMDeviceMotion?, error: Error?) in
            guard let data = data else { return }
            // 水平傾斜裝置會對閃爍效果影響更大
            let xValBooster: Double = 3.0
            // 將 x 和 y 值轉換為弧度
            let radians = atan2(data.gravity.x * xValBooster, data.gravity.y)
            // 將弧度轉換為度數
            var angle = radians * (180.0 / Double.pi)
            while angle < 0 {
                angle += 360
            }
            self?.rotateGradient(angle: angle)
        })  
    }
}
複製程式碼

注意:我們不能在模擬器或 Playground 中模擬運動跟蹤,因此要在 Xcode 專案中用真機進行測試。

在進行一些初步的設計測試之後,我們覺得有必要通過增加一個 booster 變數來改變 gravity 返回的 X 值,這樣漸變層就會以更快的速度旋轉。因此,在轉換成弧度之前,我們要先乘以 gravity.x

為了能夠讓漸變層旋轉,我們需要將裝置旋轉的角度轉換為旋轉弧的起點和終點:漸變的 startPointendPoint。StackOverflow 上有一個非常棒的解決方法,我們可以用來實現一下:

fileprivate func rotateGradient(angle: Float) {
    DispatchQueue.main.async {
        // https://stackoverflow.com/questions/26886665/defining-angle-of-the-gradient-using-cagradientlayer
        let alpha: Float = angle / 360
        let startPointX = powf(
            sinf(2 * Float.pi * ((alpha + 0.75) / 2)),
            2
        )
        let startPointY = powf(
            sinf(2 * Float.pi * ((alpha + 0) / 2)),
            2
        )
        let endPointX = powf(
            sinf(2 * Float.pi * ((alpha + 0.25) / 2)),
            2
        )
        let endPointY = powf(
            sinf(2 * Float.pi * ((alpha + 0.5) / 2)),
            2
        )
        self.gradientLayer.endPoint = CGPoint(x: CGFloat(endPointX),y: CGFloat(endPointY))
        self.gradientLayer.startPoint = CGPoint(x: CGFloat(startPointX), y: CGFloat(startPointY))
    }
}
複製程式碼

拿出一些三角學的知識!現在,我們已經將度數轉換會新的 startPointendPoint

[譯] 使用 Swift 實現原型動畫

這沒什麼……但我們能做得更好嗎?那是必須的。讓我們進入下一個階段……

CAGradientLayer 不支援徑向漸變……但這並不意味著這是不可能的!我們可以使用 CGGradient 建立我們自己的 CALayer 類 RadialGradientLayer。這裡棘手的部分就是要確保在 CGGradient 初始化期間需要將一個 CGColor 陣列強制轉換為一個 CFArray。這需要一直反覆的嘗試,才能準確地找出需要將哪種型別的陣列轉換為 CFArray,並且這些位置可能只是一個用來滿足 UnaspectPoint<CGFloat>? 型別的 CGFloat 陣列。

class RadialGradientLayer: CALayer {
    
    var colors: [CGColor] = []
    var center: CGPoint = CGPoint.zero
    
    override init() {
        super.init()
        needsDisplayOnBoundsChange = true
    }
    
    init(colors: [CGColor], center: CGPoint) {
        self.colors = colors
        self.center = center
        super.init()
    }
    
    required init(coder aDecoder: NSCoder) {
        super.init()
    }
    
    override func draw(in ctx: CGContext) {
        ctx.saveGState()
        let colorSpace = CGColorSpaceCreateDeviceRGB()
        
        // 為每種顏色建立從 0 到 1 的一系列的位置(CGFloat 型別)。
        let step: CGFloat = 1.0 / CGFloat(colors.count)
        var locations = [CGFloat]()
        for i in 0 ..< colors.count {
            locations.append(CGFloat(i) * step)
        }
        
        // 建立 CGGradient 
        guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: locations) else {
            ctx.restoreGState()
            return
        }
        let gradRadius = min(self.bounds.size.width, self.bounds.size.height)
        // 在 context 中繪製徑向漸變,從中心開始,在檢視邊界結束。
        ctx.drawRadialGradient(gradient, startCenter: center, startRadius: 0.0, endCenter: center, endRadius: gradRadius, options: [])
        ctx.restoreGState()
    }
}
複製程式碼

我們終於把所有的東西都準備好了!現在我們可以把 CAGradientLayer 替換成我們新的 RadialGradientLayer,並計算裝置重力 x 和 y 到梯度座標位置的對映。我們將重力值轉換為在 0.0 和 1.0 之間浮點數,以計算如何移動漸變層。

private func trackMotion() {
    if motionManager.isDeviceMotionAvailable {
        // 設定動作回撥觸發的頻率(秒為單位)
        motionManager.deviceMotionUpdateInterval = 2.0 / 60.0
        let motionQueue = OperationQueue()
        motionManager.startDeviceMotionUpdates(to: motionQueue, withHandler: { [weak self] (data: CMDeviceMotion?, error: Error?) in
            guard let data = data else { return }
            // 將漸變層移動到新位置
            self?.moveGradient(x: data.gravity.x, y: data.gravity.y)
        })  
    }
}

private func moveGradient(gravityX: Double, gravityY: Double) {
    DispatchQueue.main.async {
        // 使用重力作為檢視垂直或水平邊界的百分比來計算新的 x 和 y
        let x = (CGFloat(gravityX + 1) * self.bounds.width) / 2
        let y = (CGFloat(-gravityY + 1) * self.bounds.height) / 2
        // 更新漸變層的中心位置
        self.gradientLayer.center = CGPoint(x: x, y: y)
        self.gradientLayer.setNeedsDisplay()
    }
}
複製程式碼

現在讓我們回到 makeWavesaddGradientLayer 方法,並確保所有工作準備就緒:

private var gradientLayer = RadialGradientLayer()

public func makeWaves() {
    let waves = buildWaves()
    let maskLayer = CALayer()
    maskLayer.backgroundColor = UIColor.clear.cgColor
    waves.forEach({ maskLayer.addSublayer($0) })
    addGradientLayer(withMask: maskLayer)
    trackMotion()
}

private func addGradientLayer(withMask maskLayer: CALayer) {
    let colors = gradientColors.map({ $0.cgColor })
    gradientLayer = RadialGradientLayer(colors: colors, center: self.center)
    gradientLayer.mask = maskLayer
    gradientLayer.frame = self.frame
    gradientLayer.bounds = self.bounds
    self.layer.addSublayer(gradientLayer)
}
複製程式碼

下面激動的時刻來臨了……

此處視訊請到原文檢視。

現在,是非常順暢的!

附件是最後一個示例專案的完整工程,所有的程式碼處於最終狀態。我推薦你試著在裝置上執行,好好地玩下!


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章