肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

“309. 最佳买卖股票时机含冷冻期”的解题思路

LeetCode

309. 最佳买卖股票时机含冷冻期

原题

给定一个整数数组,其中第* i* 个元素代表了第 i 天的股票价格 。​

设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):

  • 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
  • 卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。

示例:

输入: [1,2,3,0,2]
输出: 3
解释: 对应的交易状态为: [买入, 卖出, 冷冻期, 买入, 卖出]

解题思路1

尝试走出所有路径。利用递归和缓存结果的方式进行优化,并设置结束条件进行约束。可惜,不能通过所有测试,提示超时。

class Solution {
    var cachedResults = [[Int]:Int]()
    
    func maxProfit(_ prices: [Int]) -> Int {
        var dealProfit = 0
        var firstDealEndAt:Int? = nil
        
         // 买,卖,冻;买,卖,冻
        for buyIndex in 0..<prices.count {
            let sellIndexStartAt = buyIndex + 1
            guard sellIndexStartAt < prices.count else {
                return dealProfit
            }
            
            let buyPrice = prices[buyIndex]
            for sellIndex in sellIndexStartAt..<prices.count {
                if let dealEndAt = firstDealEndAt {
                    guard buyIndex < dealEndAt else {
                        break
                    }
                }
                
                let sellPrice = prices[sellIndex]
                if buyPrice < sellPrice {
                    if let endDealAt = firstDealEndAt {
                        if endDealAt > sellIndex {
                            firstDealEndAt = sellIndex
                        }
                    } else {
                        firstDealEndAt = sellIndex
                    }
                    
                    var profit = sellPrice - buyPrice
                    
                    let nextIndex = sellIndex + 2
                    
                    if nextIndex < prices.count {
                        let remainPrices = Array(prices[nextIndex...])
                        let remainProfit:Int = {
                            if let profit = cachedResults[remainPrices] {
                                return profit
                            }
                            
                            let profit = maxProfit(remainPrices)
                            cachedResults.updateValue(profit, forKey: remainPrices)
                            
                            return profit
                        }()
                        
                        profit += remainProfit
                    }
                    
                    dealProfit = max(dealProfit, profit)
                }
            }
        }

        return dealProfit
    }
}

解题思路2

思路2是同时计算每一步的状态。我们知道,针对每一个数据,它可能的状态有3个,即买、卖、等待。如图:

wvR4TN8.png-1

s0[i] = max(s0[i - 1], s2[i - 1]); // 冷却可能是原地等待,也可能是卖完之后强制冷却
s1[i] = max(s1[i - 1], s0[i - 1] - prices[i]); // Stay at s1, or buy from s0 原地等待,或者购买,如果购买,则肯定是从s0状态过来的。
s2[i] = s1[i - 1] + prices[i]; // 卖出获利

最终代码:

class Solution {
    func maxProfit(_ prices: [Int]) -> Int {
        guard !prices.isEmpty else {
            return 0
        }
        
        var s0 = Array<Int>(repeating: 0, count: prices.count)
        var s1 = Array<Int>(repeating: 0, count: prices.count)
        var s2 = Array<Int>(repeating: 0, count: prices.count)
        
        s0[0] = 0
        s1[0] = -prices[0]
        s2[0] = Int.min
        
        for index in 1..<prices.count {
            s0[index] = max(s0[index - 1], s2[index - 1])
            s1[index] = max(s1[index - 1], s0[index - 1] - prices[index])
            s2[index] = s1[index - 1] + prices[index]
        }

        return max(s0.last!, s2.last!) // 最后一步,要么是冷却状态,要么是卖出状态。不可能是买入状态
    }
}

参考资料:

"916. 单词子集"的解题思路

LeetCode

916. 单词子集

原题

我们给出两个单词数组 A 和 B。每个单词都是一串小写字母。

现在,如果 b 中的每个字母都出现在 a 中,包括重复出现的字母,那么称单词 b 是单词 a 的子集。 例如,“wrr” 是 “warrior” 的子集,但不是 “world” 的子集。

如果对 B 中的每一个单词 bb 都是 a 的子集,那么我们称 A 中的单词 a通用的

你可以按任意顺序以列表形式返回 A 中所有的通用单词。

示例 1:

**输入:**A = ["amazon","apple","facebook","google","leetcode"], B = ["e","o"]
输出:["facebook","google","leetcode"]

示例 2:

**输入:**A = ["amazon","apple","facebook","google","leetcode"], B = ["l","e"]
输出:["apple","google","leetcode"]

示例 3:

**输入:**A = ["amazon","apple","facebook","google","leetcode"], B = ["e","oo"]
输出:["facebook","google"]

示例 4:

**输入:**A = ["amazon","apple","facebook","google","leetcode"], B = ["lo","eo"]
输出:["google","leetcode"]

示例 5:

**输入:**A = ["amazon","apple","facebook","google","leetcode"], B = ["ec","oc","ceo"]
输出:["facebook","leetcode"]

提示:

  1. 1 <= A.length, B.length <= 10000
  2. 1 <= A[i].length, B[i].length <= 10
  3. A[i] 和 B[i] 只由小写字母组成。
  4. A[i] 中所有的单词都是独一无二的,也就是说不存在 i != j 使得 A[i] == A[j]

解题思路1

这道题的一般思路很简单。循环A,然后循环B,B中的字符串中的字符如果在A的字符串中存在,则移除掉该字符,然后循环下一个字符。代码如下:

class Solution {
    func wordSubsets(_ A: [String], _ B: [String]) -> [String] {
        let array = A.filter { (s) -> Bool in
            for b in B {
                var s = s
                
                for bCharacter in b {
                    if let index = s.firstIndex(of: bCharacter) {
                        s.remove(at: index)
                    } else {
                        return false
                    }
                }
            }
            
            return true
        }
        
        return array
    }
}

这个思路验证时,提示会超时。也就是性能不达标。

解题思路2

我们注意到示例3中,存在两个相同的字母”oo“。

示例 3:

**输入:**A = ["amazon","apple","facebook","google","leetcode"], B = ["e","oo"]
输出:["facebook","google"]

我的优化思路是,把不重复的字符单独优化,重复的仍然按照“思路1”的方式进行处理。代码如下:

class Solution {
    func wordSubsets(_ A: [String], _ B: [String]) -> [String] {
        var bSet = Set<Character>()
        var bArray = [String]()
        
        B.forEach {
            let set:Set<Character> = Set($0)
            if set.count == $0.count {  // no duplicated
                bSet = bSet.union(set)
            } else {
                bArray.append($0)
            }
        }
        
        let array = A.filter { (s) -> Bool in
            // bSet filter
            let aSet:Set<Character> = Set(s)
            guard aSet.isSuperset(of: bSet) else {
                return false
            }
            
            
            for b in bArray {
                var s = s
                
                for bCharacter in b {
                    if let index = s.firstIndex(of: bCharacter) {
                        s.remove(at: index)
                    } else {
                        return false
                    }
                }
            }
            
            return true
        }
        
        return array
    }
}

进行验证。还是性能不达标。

最终的解题思路

没办法,只好去翻评论。结果有人说,必须全部优化重复的字母,不然就一定会超时。所谓全部优化,就是把B中所有重复的字母计算出来。

原题的B等价于所有的字母都存在,且出现的次数大于等于B中的字符串中出现的那个字符的最多次数。

单独计算B的字母频率。

func characterFrequencies(in array:[String]) -> Dictionary<Character,Int> {
    var dictionary = Dictionary<Character,Int>()
    
    for s in array {
        var sDic = [Character:Int]()
        
        for c in s {
            if let f = sDic[c] {
                sDic[c] = f + 1
            } else {
                sDic[c] = 1
            }
        }
        
        sDic.forEach { (c, f) in
            if let frequency = dictionary[c] {
                dictionary[c] = max(frequency, f)
            } else {
                dictionary[c] = f
            }
        }
    }
    
    return dictionary
}

完整代码:

class Solution {
    func wordSubsets(_ A: [String], _ B: [String]) -> [String] {
        let dictionary = characterFrequencies(in: B)
        
        let array = A.filter { (s) -> Bool in
            for (c,f) in dictionary {
                var index = s.startIndex
                
                for _ in 1...f {
                    if index < s.endIndex, let firstIndex = s[index...].firstIndex(of: c) {
                        let nextIndex = s.index(after: firstIndex)
                        index = nextIndex
                    } else {
                        return false
                    }
                }
            }
            
            return true
        }
        
        
        return array
    }
    
    func characterFrequencies(in array:[String]) -> Dictionary<Character,Int> {
        var dictionary = Dictionary<Character,Int>()
        
        for s in array {
            var sDic = [Character:Int]()
            
            for c in s {
                if let f = sDic[c] {
                    sDic[c] = f + 1
                } else {
                    sDic[c] = 1
                }
            }
            
            sDic.forEach { (c, f) in
                if let frequency = dictionary[c] {
                    dictionary[c] = max(frequency, f)
                } else {
                    dictionary[c] = f
                }
            }
        }
        
        return dictionary
    }
}

小结

我们经常需要用到字典结构来挺高应用的性能。

"329. 矩阵中的最长递增路径"的解题思路

LeetCode

329. 矩阵中的最长递增路径。这道题没有官方的题解,写下我的思路。

原题

给定一个整数矩阵,找出最长递增路径的长度。

对于每个单元格,你可以往上,下,左,右四个方向移动。 你不能在对角线方向上移动或移动到边界外(即不允许环绕)。

示例 1:

输入: nums =
[
[9,9,4],
[6,6,8],
[2,1,1]
]
输出: 4
解释: 最长递增路径为 [1, 2, 6, 9]

示例 2:

输入: nums =
[
[3,4,5],
[3,2,6],
[2,2,1]
]
输出: 4
解释: 最长递增路径是 [3, 4, 5, 6]。注意不允许在对角线方向上移动。

思路

我的思路是先将所有元素按照从小到大排序。然后开始计算,规则如下:

  1. 周边的数字如果都大于当前数字,那个当前数字只能走一步,记角标1
  2. 如果周边的数字存在小于当前数字的,因为是从小到大开始计算的,那个较小的数字之前已经计算过了。因此该方向的步数为1+角标数字。当前数字的最终角标为这四个方向中的最大的。

其实也可以按照从大到小排序。

思路图

my_solution

最后,还要避免空集。

最终代码

class Solution {
    func longestIncreasingPath(_ matrix: [[Int]]) -> Int {
        guard !matrix.flatMap({$0}).isEmpty else{
            return 0
        }
        
        var longest = 1
        var allElements = Array<Item>()
        var allElementsDictionary = Dictionary<IndexPath, Int>()
        var allSteps = Dictionary<IndexPath,Int>()
        
        for (row, rowElements) in matrix.enumerated() {
            for (column, element) in rowElements.enumerated() {
                allElements.append(Solution.Item(value: element, indexPath: Solution.IndexPath(row: row, column: column)))
                allElementsDictionary[IndexPath(row: row, column: column)] = element
            }
        }
        
        allElements.sort { (currentItem, nextItem) -> Bool in
            return currentItem.value < nextItem.value
        }
        
        allElements.forEach {
            let row = $0.indexPath.row
            let column = $0.indexPath.column
            let value = $0.value
            
            var step = 1
            
            let upIndexPath = IndexPath(row: row - 1, column: column)
            if let currentStep = getStep(currentValue: allElementsDictionary[upIndexPath], currentStep: allSteps[upIndexPath], comparingWith: value) {
                
                step = max(currentStep + 1, step)
            }
            
            let leftIndexPath = IndexPath(row: row, column: column - 1)
            if let currentStep = getStep(currentValue: allElementsDictionary[leftIndexPath], currentStep: allSteps[leftIndexPath], comparingWith: value) {
                
                step = max(currentStep + 1, step)
            }
            
            let downIndexPath = IndexPath(row: row + 1, column: column)
            if let currentStep = getStep(currentValue: allElementsDictionary[downIndexPath], currentStep: allSteps[downIndexPath], comparingWith: value) {
                
                step = max(currentStep + 1, step)
            }
            
            let rightIndexPath = IndexPath(row: row, column: column + 1)
            if let currentStep = getStep(currentValue: allElementsDictionary[rightIndexPath], currentStep: allSteps[rightIndexPath], comparingWith: value) {
                
                step = max(currentStep + 1, step)
            }
            
            allSteps[IndexPath(row: row, column: column)] = step
            longest = max(step, longest)
        }
        
        return longest
    }
    
    func getStep(currentValue:Int?, currentStep:Int?, comparingWith value:Int) -> Int? {
        if let currentValue = currentValue, currentValue < value, let currentStep = currentStep {
            return currentStep
        }
        
        return nil
    }

    struct Item {
        let value:Int
        let indexPath:IndexPath
    }
    
    struct IndexPath:Hashable {
        let row:Int
        let column:Int
    }
}

制作上架应用的视频预览

iOS

为了展示产品功能,我们可以制作并上传视频。

视频规范

我们可以通过真机录制视频,也可以通过模拟器录制。真机录制视频很简单,只需要在设备的控制中心选择录制屏幕就可以了。录完之后,经过剪辑就可以直接上传。

如果你没有特定型号的真机,也可以通过模拟器来录制视频。模拟器录制视频与真机相比,存在一些限制,本文后面会讲到如何解决这些问题。

推荐使用H.264格式。虽然它会比ProRes 422格式的文件大一些。但是这个格式现有的设备都支持硬件加速。因此保存(转码)速度最快,工作效率最高。

模拟器录制视频

录制视频

先打开Xcode,加载项目,然后选择你需要录制视频的模拟器,运行项目。当项目成功在模拟器中运行后,在终端中输入

xcrun simctl io booted recordVideo record.mov

最后的record.mov是录制的视频的文件名。它会自动保存在终端当前的文件夹。录制完成后按ctrl+c结束录制。

  • 如果你开了多个模拟器,那么可能不会录制到你当前使用的模拟器。关闭掉其余的模拟器,仅保留你需要录制的模拟器。
  • 模拟器录制的视频不完全与你的操作同步。如果你操作完成就立即结束录制,可能结尾部分会没有路上。因此,建议结尾之后进行额外的操作,比如点击按钮之类的。然后在后期制作中,将多余的部分删除,这样可以保证视频录制的完整性。
  • 虽然Xcode可以在Scheme中修改语言,来改变运行时应用的语言,但是这个对于语言的修改,是应用级别的。在录制演示文件时,应该在模拟器/设备中修改系统的语言偏好。这是因为,一些提示,比如通知权限/地理位置权限的提示窗口,是基于系统语言的。如果你只在Xcode中设置,那么就会遇到系统权限提示窗口的语言与应用语言不一致的情况。

转码

根据苹果的规范(见下图),存在两个不同的分辨率,前一个叫原生分辨率(Native resolutions),后一个叫接受的分辨率(Accepted resolutions)。前者是模拟器录制视频时得到视频的分辨率,后一个是App Store Connect上传视频时,所接受的视频分辨率。如果二者分辨率不同,就需要转码。

App Preview Resolutions

当真机录制视频时,得到的视频直接就是接受的分辨率。通过模拟器录制得到的视频,是原生分辨率,需要转码。
转码可以通过你喜欢的任意软件进行。我用的是HandBrake

添加空音轨

转码完成后,如果你直接上传模拟器录制的视频,App Store Connect会在上传后错误提示:音频不符合规范。原因是模拟器录制的视频,只有视频的部分,没有音频。因此,你需要添加额外的音轨。可以使用FFmpeg添加空音轨。

安装FFmpeg

brew install ffmpeg
brew link ffmpeg

添加空音轨

ffmpeg -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -i video.mov \
  -shortest -c:v copy -c:a aac output.mov

video.mov是源文件,output.mov是目标文件。

剪辑

剪辑我推荐使用Quicktime Player。它的保存速度是最快的。

当剪辑完成之后,有时保存按钮是灰色的。这时可以直接点击关闭,就会弹出保存对话框。

思路改变编程:记一次思路改变节省大量编程时间的小事

通用

使用微博RestAPI一直存在一条人为的限制,就是必须要在文章末尾添加一个验证链接。

比如你发微博“这是我的第一条微博。”,用微博RestAPI是没法直接发的,必须发成“这是我的第一条微博。https://poster.parussoft.com/index.html”。

如果你不加上这个链接,微博服务器就会提示错误,不让发微博。

这个链接对于用户其实是没有意义的。并且直接加在微博末尾,就会有人点击它,然后奇怪为什么会有一个跟内容没什么关系的链接在那里。

为了尽量减少用户点击这个链接的可能,咕唧从2018年3月底发布的1.7.13版开始,增加了一个折叠隐藏该链接的功能。利用的原理是,微博官方微博网页版和客户端,会在内容过长时,自动折叠微博,而咕唧会在字数允许的情况下,添加空行,将该链接隐藏起来。

但是这个功能存在限制,因为只有官方的网页版和客户端才有折叠的功能。其它第三方的客户端,官方的html5版以及国际版,都不具备折叠的功能。在这些微博客户端中,咕唧发布的微博就会变得特别长。因此,时不时的,我就会收到评论或者私信,问为什么我发的微博会那么长?

咕唧2决定彻底解决这个问题

我的思路是,既然这个链接必不可少,那么我可以将其从无用的链接,转换为有用的链接。这样用户点击了也不会显得突兀。类似这样的查询“https://poster.parussoft.com/jumping.php?query_string”。

weibo_forcing_ur

思路1

思路确定好之后,我发现,生成的链接必须经过服务器的处理才可以。那么就有两个思路:

  1. 直接保存对应的html文件到磁盘。(这样占用磁盘较多)
  2. 将查询条件和结果保存的数据库,动态查询。(这样占用磁盘少,但是CPU占用高。因此还需要缓存查询结果。)

我觉得第2条更好。这样就需要在服务器端使用PHP和Sqlite。思路总结完,我在服务器远程安装好PHP和Sqlite3之后,就睡觉了。

思路2

第二天,开始继续前一天的工作。我发现微博账户的用户页,其实是需要和其它链接分开的。因为微博账户的用户页,可以直接根据微博用户的id自动生成。我又想到,既然可以自动生成,那么该项就不用保存到数据库,直接通过id生成并跳转就可以了。

之后,我又想到,链接的跳转也是同样的道理,可以直接将链接做为查询的字段,这样就能直接跳转了。

这样一来,思路1中的PHP、数据库、缓存就都不需要了,服务器端只需要写一个能判断查询类型并生成跳转链接的Javascript网页就全搞定。

结论

不同的思路,需要的工作量完全不同。多思考,比埋头苦干,效率更高。

iOS项目CocoaPods的安装与使用

SwiftRealmSwiftXcode

安装

网上看了好几篇文章,综合起来才成功,这个是在macOS Sierra 10.12.4 (16E195)下正确安装CocoaPods的步骤:

1. 替换源为镜像

gem sources -l

查看当前的gem源的地址,由于墙的缘故,如果源是https://rubygems.org/,我们需要将它删除。

gem sources --remove https://rubygems.org/

然后替换为ruby china的镜像。

gem sources --add https://gems.ruby-china.org/

确认源替换是否成功

gem sources -l

2. 升级gem

系统自带的gem版本较低,使用时会出现莫名其妙的问题,因此要把它升级到最新版

sudo gem update --system

如果上面代码提示没有权限,改成

sudo gem update -n /usr/local/bin --system

3. 安装cocoapods

在macOS 10.11和10.12中安装时,安装到/usb/bin会提示错误,因此需要安装到/usb/local/bin里

sudo gem install -n /usr/local/bin cocoapods

4. 替换源

默认的源更新起来很慢,需要替换才能变快。如果你想使用默认的,可以跳过这一步。

查看当前的源:

pod repo 

删除master:

pod repo remove master

添加清华的源:

git clone https://mirrors.tuna.tsinghua.edu.cn/git/CocoaPods/Specs.git ~/.cocoapods/repos/master

更新源:

pod repo update

5. 更新pod

如果你做过上一步,可以跳过这一步。

pod setup

这步时间较长,需要耐心等待。

使用

如果你使用了第4步的替换源,那么需要在你的每个Podfile的最前面,添加一行。

source 'https://mirrors.tuna.tsinghua.edu.cn/git/CocoaPods/Specs.git'
  1. 新建一个iOS的项目。
  2. 在终端,进入到该项目的文件夹。
  3. 执行pod init
  4. 打开自动生成的Podfile文件
  5. 在其中添加你要使用的框架的名称
  6. 保存好Podfile文件
  7. 执行pod install
  8. 在Xcode中,关闭你的项目
  9. 在文件夹,找到pod生成的.xcworkspace文件,打开它。
  10. 像平常一样使用框架就可以了。

比如我新建的项目叫PodTest,添加了RealmSwift,最终的Podfile如下:

这其中大部分的内容都是pod自动生成的,我需要修改/添加的只有第三行,iOS的版本。第四行,去除警告。以及pod 'RealmSwift'的那一行。

最后的那段,是RealmSwift网站要求的。你需要什么框架,就到那个框架的官网,按照提示复制粘贴上去就可以了。

最后记得,每次修改完Podfile之后,都要记得运行一遍pod install

# Uncomment the next line to define a global platform for your project
platform :ios, '10.3'
inhibit_all_warnings!

target 'PodTest' do
  # Comment the next line if you're not using Swift and don't want to use dynamic frameworks
  use_frameworks!
  pod 'RealmSwift'

  # Pods for PodTest

  target 'PodTestTests' do
    inherit! :search_paths
    # Pods for testing
  end

  target 'PodTestUITests' do
    inherit! :search_paths
    # Pods for testing
  end

end

post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['SWIFT_VERSION'] = '3.1'
    end
  end
end

小技巧

  1. 第四行的inhibit_all_warnings!,可以使得Xcode不对框架中不当的帮助说明生成警告。因为框架是第三方的,我们即便看到了,也不会修改它。
  2. 有时会由于网络问题导致下载的框架不全,此时可以使用这个命令重新下载。
pod deintegrate && pod install

参考资料

macOS下自定义文件类型(传统篇)

macOS

起因

最近开发macOS的应用,需要想Xcode那样打开.xcloc结尾的文件夹。如图,虽在在Finderzh-Hans.xcloc显示为文件夹,但是在Xcode打开它时,它却显示为一个文件(前面没有文件夹的三角形标志)。

Xcode open xcloc folde

在Xcode中的Info.plist里,xcloc文件是定义在Exported Type UTIs中的。因此,如果只是想达到和Xcode同样的效果,只需要将这段xml片段,复制到你自己的Info.plist里。

Xcode xclo

更进一步

Xcode的做法是只在它的导入中将.xcloc的文件夹视为文件,而在Finder中,.xcloc的文件夹还是文件夹的状态。但是,如果是想直接通过双击的方式打开.xcloc的文件夹,则需要Finder同样显示.xcloc的文件夹视为文件。

原理

macOS通过UTI来判断数据的类型,若UTI不存在,则通过扩展名判断。

Finder中我们见到的文件文件夹,从本质上讲,都是存储在电脑中的数据。不过为了管理的需要,我们将单独的数据称之为文件,将包含了其它数据的数据称之为文件夹
特别的,macOS允许我们将特定的文件夹,在Finder中显示为文件。通过在Info.plist中设定LSTypeIsPackage属性为真可以让Finder文件夹看作是文件
但是,这个属性

英文版

Show certain folder as a file as Xcode does

参考资料:

macOS中自定义应用名的坑(更新)

macOS

我们知道在Info.plist中添加CFBundleDisplayName可以更改应用的显示名称,也就是自定义应用名。iOS可以在Xcode的项目->目标->通用中直接设定。

iOS指定Display Name

macOS的目标没有这个设置,必须手动添加。我们直接在Info.plist中添加这个属性。然后在需要翻译的目标语言的InfoPlist.strings中翻译成对应的语言。

这里面有一个坑,导致了一些问题。实践中,在macOS中CFBundleDisplayName的值,必须是包名的最后一项,不然就不会生效。比如,你有一个叫应用叫"com.foo.Orange",然后直接CFBundleDisplayName设置为“橘子”,以为这样就不用翻译了。可你用Xcode编译之后,生成的应用却是“Orange.app”而不是你想要的“橘子.app”。

也就是说,在macOS中,你只能将CFBundleDisplayName设置成“Orange”,然后在翻译文件中翻译。

另外,在测试中我发现也可以将CFBundleDisplayName设置为“空”也能生效。除了设置为包名和空之外,其余的方式都会导致该设置无效。

实际上,这个坑属于bug,确切的说,是macOS中Finder的bug。因为你解包看的话,Xcode生成的文件都是没有问题的。因此只能认为是Finder在读取包时额外作出了错误的限制。我已经向苹果报了这个bug,就不知道苹果肯不肯修了。

收到苹果回复了,苹果认为这个是已知的特性

This is correct behaviour on macOS. Because users can rename apps, CFBundleDisplayName must match the file system name of the app bundle for localization to become active. This behaviour is documented.
See https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html#//apple_ref/doc/plist/info/CFBundleDisplayName

苹果的文档是这么写的。

In macOS, before displaying a localized name for your bundle, the Finder compares the value of this key against the actual name of your bundle in the file system. If the two names match, the Finder proceeds to display the localized name from the appropriate InfoPlist.strings file of your bundle. If the names do not match, the Finder displays the file-system name.

如图,这实际上是两个路径

分析

  1. Xcode生成应用,使用的名称,是项目名称。
  2. Finder上的应用名(文件名),由于用户可以更改,可能与开发者当初设定的不一致。其判断规则是,Finder中应用的文件名,如果与应用开发语言中的CFBundleDisplayName一致,则显示翻译,否则不显示。

原理是搞清楚了。不过我觉得苹果还是有点儿蠢的。开发语言中的CFBundleDisplayName实际上,只是起到判断的作用,而并没有体现为Display Name。这是与其名字不符的。实际上,开发者并没有其它任何办法更改默认的显示名,只能更改项目名。

macOS应用修改默认名称的唯一方法

更改项目名是唯一的方法。
更改项目名

可以在Xcode如图的位置更改项目名。其它方式都只能更改目标语言的显示名,而不能更改默认语言的。