65.9K
CodeProject 正在变化。 阅读更多。
Home

在原生移动应用开发中为 Android 和 iOS 生成图标集

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2023 年 8 月 31 日

CPOL

4分钟阅读

viewsIcon

22884

使用 PowerShell 脚本生成原生移动应用的图标。

引言

本文适合已具有使用 Xamarin 开发 Android 和 iOS 应用经验的开发者。虽然市面上有很多图标库,无论是免费还是商业的,但有时您可能希望从现有图像文件(最好是 SVG 文件)生成图标集。

本文旨在分享我在开发原生移动应用时,从 SVG 文件生成各种尺寸图标的经验。虽然我主要使用的开发工具是 Xamarin,但此处提到的原理和工具即使您一直在使用 Xcode 或 Android Studio 等其他工具,也应适用。

Xcode 14+ 支持单尺寸应用图标。Android Studio 则包含Image Asset Studio,可从 Material 图标和自定义图像生成应用图标。但是,如果您为了持续集成而倾向于不使用这种交互式方式,或者您正在使用 Xamarin,请继续阅读。

背景

几年前,我开发了Visual Acuity Charts,用于测试远视力,以检查近视的早期迹象。有一些免费的 SVG 图标,我使用了一些在线转换工具来生成所需的图标集。我一直在使用 Nika Nikabadze 为 Visual Sutido 创建的“Material icons generator”以及https://appicon.co/

Microsoft Visual Studio 的 Android 库项目中的 Android 图标资源

iOS 图标资产

然而,使用这些 VS 扩展或在线工具的过程相当麻烦。

  • 使用 VS 扩展时,过多的用户交互让我感到烦恼。
  • 使用在线工具:上传 SVG 文件 -> 配置 -> 生成 -> 等待 -> 下载 zip -> 解压到项目文件夹。总的来说,这种流程不利于持续集成,因此我编写了一系列 PowerShell 脚本来生成图标集。

总的来说,这两个工具都相当不错,但是,如果您也遇到了我所经历的麻烦,可以尝试本文介绍的方法。

必备组件

SVG 图标库

还有许多其他免费或商业的图标库。

备注

  • 请确保免费图标的使用符合相应的许可,例如 Google Material 图标库使用的SIL Open Font License (OFL)
  • Google Material Icons 网站提供了 Android 和 iOS 的图像集下载(ZIP 格式)。

Inkscape

"Inkscape 是适用于 GNU/Linux、Windows 和 macOS 的免费开源矢量图形编辑器。"

作为一名软件开发者,我偶尔会进行休闲和简单的图形设计,而且我不想购买专业图形设计者通常使用的功能强大且复杂的全能图形设计工具。Inkscape 满足了我的需求。

组合简单图标

例如,我使用现有的 SVG 文件来组合应用启动图标,而不是从头开始绘制一切。

应用启动器图标

Google Material Icons 中的可见性图标

尽管如此,Inscape 提供了丰富的功能,供您从头开始绘制复杂的图标或图像。

调整现有图标以符合 Google 和 Apple 的设计指南

有时,您可能会发现某些图标不太符合特定的设计指南,并希望调整其尺寸和边距。

参考文献

Using the Code

以下 PowerShell 脚本利用了 Inkscape 的命令行功能,并且脚本的编写参考了上述图标设计指南。

Android

以下脚本用于生成按钮图标和应用图标。请阅读脚本内的注释。

svgForAndroid.ps1

param([string]$svgFile, [int]$width, [int]$height, [boolean]$forAppIcon, 
      [string]$appIconName)
# Create icons for drawable and mipmap
# Examples:
# ./svgForAndroid.ps1 -svgFile "my.svg" -width 36
# ./svgForAndroid.ps1 "my.svg" 36
# For App Launcher Icons, 
# ref: https://developer.android.com.cn/training/multiscreen/screendensities
# ./svgForAndroid.ps1 -svgFile "my.svg" -width 48 -forAppIcon $true 
# and this is excluding 36x36 ldpi
# The script file and the svg file should be in the same folder, 
# and the generated files will be in a sub-folder.
# For convenience of developing using Xamarin, follow such convension:
# Rename the svg file to something like send_36.svg if you want 36pt.
# Remarks: Android requires all resource file names are in lower case.

cd $PSScriptRoot

$baseFileName=[System.IO.Path]::GetFileNameWithoutExtension($svgFile)

function export([string]$subDir, [decimal]$ratio){
    $dir= [System.IO.Path]::Combine($baseFileName,$subDir)
    $exportedFileName=If ($forAppIcon) {If ($appIconName) {$appIconName+".png"} 
    Else {"5367533/ic_launcher.png"}} Else {$baseFileName+"_"+ $width + ".png"}
    $exported= [System.IO.Path]::Combine($dir, $exportedFileName)
    $dwidth=[int]($width * $ratio)
    $dheight=0
    if ($height -gt 0){$dheight =  $height * $ratio} Else {$dheight = $dwidth}
    $arguments="$svgFile --export-filename=$exported -w $dwidth -h $dheight -d 96"
    New-Item -ItemType Directory -Force -Path $dir

    $procArgs = @{
        FilePath         = "C:\Program Files\Inkscape\bin\inkscape.exe"
        ArgumentList     = $arguments
        PassThru         = $true
    }
    $processTsc = Start-Process @procArgs
}

If ($forAppIcon){
    export "mipmap-mdpi" 1
    export "mipmap-hdpi" 1.5
    export "mipmap-xhdpi" 2
    export "mipmap-xxhdpi" 3
    export "mipmap-xxxhdpi" 4
} Else {
    export "drawable-mdpi" 1
    export "drawable-hdpi" 1.5
    export "drawable-xhdpi" 2
    export "drawable-xxhdpi" 3
    export "drawable-xxxhdpi" 4
}

iOS

图像集

以下脚本会生成三个 png 文件和Contents.json

svgForIOSImageset.ps1

param([string]$svgFile, [Int32]$width, [Int32]$height, [boolean]$original)
# Create icons for imageset.
# Examples:
# .\svgForAndroid.ps1 -svgFile "my.svg" -width 36 -original $true
# .\svgForIOSImageset.ps1 -svgFile "my.svg" -width 36
# If $original is false, template-rendering-intent in Contents.json 
# will become template  for visual effects such as replacing colors.
# The script file and the svg file should be in the same folder, 
# and the generated files will be in a sub-folder.

cd $PSScriptRoot

$baseFileName=[System.IO.Path]::GetFileNameWithoutExtension($svgFile)
$dir= $baseFileName + "_" + $width + ".imageset"
New-Item -ItemType Directory -Force -Path $dir

function export([decimal]$ratio){
    $exportedFileName=$baseFileName + "_" + $width +"pt_" + $ratio + "x.png"
    $exported= [System.IO.Path]::Combine($dir, $exportedFileName)
    $dwidth=[int]($width * $ratio)
    $dheight=0
    if ($height -gt 0){$dheight = $height * $ratio} Else {$dheight = $dwidth}
    $arguments="$svgFile --export-filename=$exported -w $dwidth -h $dheight -d 96"
    Write-Host $arguments
    $procArgs = @{
        FilePath         = "C:\Program Files\Inkscape\bin\inkscape.exe"
        ArgumentList     = $arguments
        PassThru         = $true
    }
    $processTsc = Start-Process @procArgs
    return $exportedFileName
}

$f1=export 1
$f2=export 2
$f3=export 3
$intent=If ($original) {"original"} Else {"template"}

$contentsTemplate=
@"
{
    "images": [
        {
            "filename": "$f1",
            "idiom": "universal",
            "scale": "1x"
        },
        {
            "filename": "$f2",
            "idiom": "universal",
            "scale": "2x"
        },
        {
            "filename": "$f3",
            "idiom": "universal",
            "scale": "3x"
        }
    ],
    "info": {
        "author": "whocare",
        "template-rendering-intent": "$intent",
        "version": 1
    }
}
"@

$jsonFile=[System.IO.Path]::Combine($dir, "Contents.json")
Set-Content $jsonFile $contentsTemplate

运行脚本后,您将在一个类似 myicon.imageset 的文件夹中获得这些文件。

应用图标

运行脚本后,您将在一个类似 myAppIcon.appiconset 的文件夹中获得 26 个文件,包括 Contents.json

svgForIOSAppIconSet.ps1

param([string]$svgFile)
# Create AppIcons.appiconset of Assets.xcassets
# Examples:
# ./svgForIOSAppIconSet.ps1 -svgFile "my.svg"
# The script file and the svg file should be in the same folder, 
# and the generated files will be in a sub-folder like "my.appiconset".
cd $PSScriptRoot

$baseFileName=[System.IO.Path]::GetFileNameWithoutExtension($svgFile)
$dir= $baseFileName + ".appiconset"
New-Item -ItemType Directory -Force -Path $dir

function export([decimal]$size){
    $exportedFileName=$size.ToString() + ".png"
    $exported= [System.IO.Path]::Combine($dir, $exportedFileName)
    $arguments="$svgFile --export-filename $exported -w $size -h $size"

    $procArgs = @{
        FilePath         = "C:\Program Files\Inkscape\bin\inkscape.exe"
        ArgumentList     = $arguments
        PassThru         = $true
    }
    $processTsc = Start-Process @procArgs
    return $exportedFileName
}

function exportForWatch(){
    $exportedFileName="watch.png"
    $exported= [System.IO.Path]::Combine($dir, $exportedFileName)
    $arguments="$svgFile --export-filename $exported -w 55 -h 55"

    $procArgs = @{
        FilePath         = "C:\Program Files\Inkscape\bin\inkscape.exe"
        ArgumentList     = $arguments
        PassThru         = $true
    }
    $processTsc = Start-Process @procArgs
    return $exportedFileName
}

export 20
export 29
export 32
export 40
export 50
export 57
export 58
export 60
export 64
export 72
export 76
export 80
export 87
export 100
export 114
export 120
export 128
export 144
export 152
export 167
export 180
export 256
export 512
export 1024

exportForWatch 

$contentsTemplate=
@"
{
    "images": [
        {
            "size": "60x60",
            "expected-size": "180",
            "filename": "180.png",

            "idiom": "iphone",
            "scale": "3x"
        },
        {
            "size": "40x40",
            "expected-size": "80",
            "filename": "80.png",

            "idiom": "iphone",
            "scale": "2x"
        },
        {
            "size": "40x40",
            "expected-size": "120",
            "filename": "120.png",

            "idiom": "iphone",
            "scale": "3x"
        },
        {
            "size": "60x60",
            "expected-size": "120",
            "filename": "120.png",

            "idiom": "iphone",
            "scale": "2x"
        },
        {
            "size": "57x57",
            "expected-size": "57",
            "filename": "57.png",

            "idiom": "iphone",
            "scale": "1x"
        },
        {
            "size": "29x29",
            "expected-size": "58",
            "filename": "58.png",

            "idiom": "iphone",
            "scale": "2x"
        },
        {
            "size": "29x29",
            "expected-size": "29",
            "filename": "29.png",

            "idiom": "iphone",
            "scale": "1x"
        },
        {
            "size": "29x29",
            "expected-size": "87",
            "filename": "87.png",

            "idiom": "iphone",
            "scale": "3x"
        },
        {
            "size": "57x57",
            "expected-size": "114",
            "filename": "114.png",

            "idiom": "iphone",
            "scale": "2x"
        },
        {
            "size": "20x20",
            "expected-size": "40",
            "filename": "40.png",

            "idiom": "iphone",
            "scale": "2x"
        },
        {
            "size": "20x20",
            "expected-size": "60",
            "filename": "60.png",

            "idiom": "iphone",
            "scale": "3x"
        },
        {
            "size": "1024x1024",
            "filename": "1024.png",
            "expected-size": "1024",
            "idiom": "ios-marketing",

            "scale": "1x"
        },
        {
            "size": "40x40",
            "expected-size": "80",
            "filename": "80.png",

            "idiom": "ipad",
            "scale": "2x"
        },
        {
            "size": "72x72",
            "expected-size": "72",
            "filename": "72.png",

            "idiom": "ipad",
            "scale": "1x"
        },
        {
            "size": "76x76",
            "expected-size": "152",
            "filename": "152.png",

            "idiom": "ipad",
            "scale": "2x"
        },
        {
            "size": "50x50",
            "expected-size": "100",
            "filename": "100.png",

            "idiom": "ipad",
            "scale": "2x"
        },
        {
            "size": "29x29",
            "expected-size": "58",
            "filename": "58.png",

            "idiom": "ipad",
            "scale": "2x"
        },
        {
            "size": "76x76",
            "expected-size": "76",
            "filename": "76.png",

            "idiom": "ipad",
            "scale": "1x"
        },
        {
            "size": "29x29",
            "expected-size": "29",
            "filename": "29.png",

            "idiom": "ipad",
            "scale": "1x"
        },
        {
            "size": "50x50",
            "expected-size": "50",
            "filename": "50.png",

            "idiom": "ipad",
            "scale": "1x"
        },
        {
            "size": "72x72",
            "expected-size": "144",
            "filename": "144.png",

            "idiom": "ipad",
            "scale": "2x"
        },
        {
            "size": "40x40",
            "expected-size": "40",
            "filename": "40.png",

            "idiom": "ipad",
            "scale": "1x"
        },
        {
            "size": "83.5x83.5",
            "expected-size": "167",
            "filename": "167.png",

            "idiom": "ipad",
            "scale": "2x"
        },
        {
            "size": "20x20",
            "expected-size": "20",
            "filename": "20.png",

            "idiom": "ipad",
            "scale": "1x"
        },
        {
            "size": "20x20",
            "expected-size": "40",
            "filename": "40.png",

            "idiom": "ipad",
            "scale": "2x"
        },
        {
            "idiom": "watch",
            "filename": "watch.png",

            "subtype": "38mm",
            "scale": "2x",
            "size": "86x86",
            "expected-size": "172",
            "role": "quickLook"
        },
        {
            "idiom": "watch",
            "filename": "watch.png",

            "subtype": "38mm",
            "scale": "2x",
            "size": "40x40",
            "expected-size": "80",
            "role": "appLauncher"
        },
        {
            "idiom": "watch",
            "filename": "watch.png",

            "subtype": "42mm",
            "scale": "2x",
            "size": "98x98",
            "expected-size": "196",
            "role": "quickLook"
        },
        {
            "idiom": "watch",
            "filename": "watch.png",

            "subtype": "38mm",
            "scale": "2x",
            "size": "24x24",
            "expected-size": "48",
            "role": "notificationCenter"
        },
        {
            "idiom": "watch",
            "filename": "watch.png",

            "subtype": "42mm",
            "scale": "2x",
            "size": "27.5x27.5",
            "expected-size": "55",
            "role": "notificationCenter"
        },
        {
            "size": "29x29",
            "expected-size": "87",
            "filename": "87.png",

            "idiom": "watch",
            "role": "companionSettings",
            "scale": "3x"
        },
        {
            "idiom": "watch",
            "filename": "watch.png",

            "subtype": "42mm",
            "scale": "2x",
            "size": "44x44",
            "expected-size": "88",
            "role": "longLook"
        },
        {
            "size": "29x29",
            "expected-size": "58",
            "filename": "58.png",

            "idiom": "watch",
            "role": "companionSettings",
            "scale": "2x"
        },
        {
            "size": "1024x1024",
            "expected-size": "1024",
            "filename": "1024.png",

            "idiom": "watch-marketing",
            "scale": "1x"
        },
        {
            "size": "128x128",
            "expected-size": "128",
            "filename": "128.png",

            "idiom": "mac",
            "scale": "1x"
        },
        {
            "size": "256x256",
            "expected-size": "256",
            "filename": "256.png",

            "idiom": "mac",
            "scale": "1x"
        },
        {
            "size": "128x128",
            "expected-size": "256",
            "filename": "256.png",

            "idiom": "mac",
            "scale": "2x"
        },
        {
            "size": "256x256",
            "expected-size": "512",
            "filename": "512.png",

            "idiom": "mac",
            "scale": "2x"
        },
        {
            "size": "32x32",
            "expected-size": "32",
            "filename": "32.png",

            "idiom": "mac",
            "scale": "1x"
        },
        {
            "size": "512x512",
            "expected-size": "512",
            "filename": "512.png",

            "idiom": "mac",
            "scale": "1x"
        },
        {
            "size": "16x16",
            "expected-size": "32",
            "filename": "32.png",

            "idiom": "mac",
            "scale": "2x"
        },
        {
            "size": "32x32",
            "expected-size": "64",
            "filename": "64.png",

            "idiom": "mac",
            "scale": "2x"
        },
        {
            "size": "512x512",
            "expected-size": "1024",
            "filename": "1024.png",

            "idiom": "mac",
            "scale": "2x"
        }
    ]
}
"@

$jsonFile=[System.IO.Path]::Combine($dir, "Contents.json")
Set-Content $jsonFile $contentsTemplate 

关注点

您可以根据您的 SDLC / CI 流程调整本文提供的 PowerShell 脚本。

我在开发原生移动应用方面的经验仅限于智能手机和平板电脑。因此,如果您正在为智能手表或智能电视开发原生应用,您可能需要根据 respective 图标设计规范添加几行代码。

MAUI

要开发一个同时支持 Android 和 iOS 的原生应用,您需要至少创建两个特定平台的应用项目,一个用于 Android,另一个用于 iOS,而共享库代码包含在 Xamarin.Forms 项目和 .NET Standard 项目中。

使用MAUI,您只需要一个应用项目即可支持 Android、iOS、Windows 和 Mac 等。并且图标源可以是矢量图像(SVG 文件)。显然,MAUI 会读取 SVG 文件并根据各个平台的要求生成 PNG 文件,就像本文中的脚本文件所做的那样。这听起来很自然、很有前景而且富有成效。

几年前,我曾将Angular 的官方教程应用“Tour of Heroes”重写为 Xamarin,以演示如何在实际项目中WebApiClientGen 。在 2023 年 10 月,我已将该应用迁移到 MAUI。Xamarin 构建的 Android 可部署文件大约为 22MB,而 MAUI 构建的则约为 33MB,尽管两者都是发布版本。

这种尺寸的膨胀显然是不可取的。我很想看看 Microsoft 是否能在 .NET Conf 2023 之后和 Xamarin 定于 2024 年 4 月退役之前解决这个问题。

历史

  • 2023 年 8 月 31 日:初稿
© . All rights reserved.