Generating custom SF Symbols from existing SVG files

(日本語版はこちらへ)

Hello and Happy New Year! I'm Vincent (@vincentisambart) from the Mobile Infrastructure team here at Cookpad Japan.

Recently, Apple has been putting a lot of energy into SF Symbols, symbols to use on your app's screens. SF Symbols allows you to not only use symbols created by Apple, but also custom ones you made yourself. To create custom symbols, following the official workflow seemed quite time-consuming, so I tried to automatically generate custom symbols from existing SVG files instead.

How it started

For symbols, single color icons, the iOS Cookpad Japan app has been using CookpadSymbols, a font made only of symbols, for quite a while. However, a few months ago, our designers asked if we could switch from using a font to using SVG files.

The icons used in the font were already made from SVGs, but designers wanted to simplify the process, and not have to load the vector files into some online tool to generate a new version of the font every time a change was made. And these days SVG files can be used directly in a lot of places so that should be doable.

You can see how a few CookpadSymbols symbols look like below.

f:id:vincentisambart:20201228112920p:plain

On iOS there are 3 different ways to use SVGs as symbols.

  1. Use them as pixel images of a specific size (as if they had been converted to PNG)
  2. Use them as vector data ("Preserve Vector Data" setting in asset catalogs)
    • Starting from Xcode 12 you can use SVG files directly, but to use them as vector data on iOS 12 and below, it seems that you have to first convert them to PDF.
  3. Use them as custom symbols (custom SF Symbols)
    • Requires iOS 13 and above.

The iOS Cookpad Japan app had been using symbols from a font, changing their displayed size depending on the screen, so using fixed size images would be pretty inconvenient. The source is a vector image so you can easily generate a lot of differently sized images, but still.

Apple has recently been pushing SF Symbols, and at the time our designers asked for switching from a symbol font, we were already talking about stopping support for iOS 12 very shortly after. So I decided to go for choice 3. If we ended up delaying stopping support for iOS 12 for a long period of time, or if implementation of that choice ended up being too complicated, I could always fall back to choice 2.

In the end, stopping support of iOS 12 was delayed a bit, but I still went to choice 3. Before explaining how the implementation went, we should probably have a better look at custom symbols.

Custom Symbols

To introduce custom symbols, we first have to talk about SF Symbols. SF Symbols is a feature available starting from iOS 13, providing symbols (one color icons that can be used at any size) that developers can use in their apps. In contrast with normal fixed size images, they are made to be used in conjunction with text: their size is specified with a font size, and their baseline can be properly aligned with text.

You can see a list and search through SF Symbols inside the SF Symbols app provided by Apple as you can see in the screenshot below.

f:id:vincentisambart:20201228112934p:plain

You are not limited to the symbols provided by Apple, you can also use your own custom symbols. To make you own custom symbols, if you follow the official guide, you first have to choose in the SF Symbols app an existing symbol close to the one you want to make, and export it as SVG. You then edit it in a vector graphics editor (like Illustrator) to get a symbol usable in Xcode.

The existing symbol font CookpadSymbols had close to 300 symbols. Editing them one by one would take a lot of effort. We are trying to make life easier for designers, so giving them more work would not make much sense. Automation is a bit part of the job of developers, and after all SVG is XML, making it pretty malleable, so I started working on it.

Trying to load an existing SVG into Xcode

The SVG files were already prepared by designers (by the way those SVGs are also used on the Web and Android). You can see one of those SVGs below (no need to try to understand the content in detail).

<svg height="64" width="64" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="12" r="8"/><path d="M52.7 50.941l-7.913-4.396-3.335-8.34-.642-8.994 3.257.723 1.517 6.826a3.504 3.504 0 004.176 2.658 3.5 3.5 0 002.658-4.176l-2-9a3.5 3.5 0 00-2.658-2.658l-9-2a3.416 3.416 0 00-1.276-.037c-.16-.022-.319-.047-.484-.047h-8c-.163 0-.32.031-.479.055a3.48 3.48 0 00-3.254 1.26l-7.163 8.953-4.679.781a3.501 3.501 0 001.15 6.904l6-1a3.513 3.513 0 002.158-1.266l4.18-5.225 1.346 6.279-6.126 8.752A3.503 3.503 0 0021.5 49v9a3.5 3.5 0 107 0v-7.896l5.322-7.604h1.808l3.12 7.801a3.51 3.51 0 001.55 1.76l9 5a3.5 3.5 0 004.759-1.36 3.5 3.5 0 00-1.359-4.76z"/></svg>

If you try loading it in a web browser or vector graphics editor, you get this.

f:id:vincentisambart:20201228112940p:plain

Xcode 12 can read SVGs, so without thinking too much, if you try to drag & drop the same file in an asset catalog, you get the following.

f:id:vincentisambart:20201228113043p:plain

Pretty different to the expected result... Looking at the content of the SVG, the file content seemed optimized (not containing any non-required information), so I thought the file not appearing correctly might be due to Xcode not handling that type of optimized SVG properly. Looking more closely at the content, even though it seems optimized, in path, having some numbers starting with 0s looked unnatural to me (for example 004.176). It is just a text (XML) file, so after having a quick look at the SVG specs, I loaded the SVG in a text editor, and tried adding spaces after each of those unnatural 0s (for example 004.1760 0 4.176). Loading the modified file into Xcode gave the following.

f:id:vincentisambart:20201228113052p:plain

Not perfect yet, but still better than we had before. So it seems that Xcode's SVG parsing is indeed a bit limited.

Compensating for Xcode's poor understanding of the SVG format would require spending time to learn and understand the SVG specs, so before even thinking of trying that, first isn't there a tool that would do it for us?

Looking at the repository for the SVGs that the designers had created, it seemed they were using a tool called SVGO for optimizing the SVGs. Looking at that tool's settings, there was a path-related setting that looked related. After adding the 2 lines below to the already existing svgo.yml setting file, and then running SVGO, all updated SVGs could now be properly loaded into Xcode.

  - convertPathData:
      # Xcode doesn't handle properly paths without spaces after flags
      noSpaceAfterFlags: false

f:id:vincentisambart:20201228113102p:plain

SVG being loaded properly with only setting change was a relief.

With the new settings, the SVG files became a tiny bit bigger due to the added spaces, but using different settings depending on the platform would be cumbersome, so we chose to use these settings for all platforms.

Being able to read these SVGs into Xcode was an important first step, but we want to handle them not as normal images but as symbols, so we now have to prepare symbols from these SVGs.

Providing symbols

Following the official guide, providing symbols first requires to export an existing symbol from the SF Symbols app. If you export what seems to be one of the simplest symbols circle, you get an SVG that looks like the following.

f:id:vincentisambart:20201228113116p:plain

For one symbol you can provide 3 sizes and 8 weights, and providing all of them would probably be the best, but reading the official guide, only Regular Medium (Regular-M) is required. I chose to only provide the required shape at first. If providing other sizes just requires some simple scaling, generating other sizes afterwards should be pretty easy.

The goal was to make managing symbols easy, so I decided to insert the SVGs' content into a template exported from the SF Symbols app, not by hand as the guide said, but automatically with a script. I wrote that script in Ruby as that was the easiest for me, but you should be able to do it pretty easily in any language with a good XML handling library. I tried making the code below simple and added many comments, so you should be able to follow along without knowing much Ruby. In the code I'm using CSS selectors as much as possible (#abcd points to nodes in the XML for which id is abcd).

We first start with a simple setup. Load the library we are going to use, define constants, and load the template.

require "nokogiri" # Load the XML library we are going to use.

# Path to file exported from the SF Symbols app
TEMPLATE_PATH = "path/to/circle.svg"
# Path to one of the SVGs provided by the designers
SOURCE_SVG_PATH = "icon.svg"
# Path to the SVG we are generating
DESTINATION_SVG_PATH = "icon-symbol.svg"

# Expected icon size
ICON_WIDTH = 64
ICON_HEIGHT = 64
# Additional scaling to have a size closer to Apple's provided SF Symbols
# (I just tried different values and that looked pretty close)
ADDITIONAL_SCALING = 1.7
# Width of #left-margin and #right-margin inside the SVG
MARGIN_LINE_WIDTH = 0.5
# Additional white space added on each side
ADDITIONAL_HORIZONTAL_MARGIN = 4

# Load the template.
template_svg = File.open(TEMPLATE_PATH) do |f|
  # To generate a better looking SVG, ignore whitespaces.
  Nokogiri::XML(f) { |config| config.noblanks }
end

The template is an XML (SVG) split into 3 groups (#Notes, #Guides, #Symbols).

<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 149-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
       "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="3300" height="2200">
 <!--glyph: "uni100000.medium", point size: 100.000000, font version: "Version 16.0d18e1", template writer version: "8"-->
 <g id="Notes">
  (...)
 </g>
 <g id="Guides">
  (...)
 </g>
 <g id="Symbols">
  (...)
 </g>
</svg>

The symbols are included into the #Symbols as you can see below.

 <g id="Symbols">
  <g id="Black-L" transform="matrix(1 0 0 1 2854.05 1556)">
   <path d="(...)"/>
  </g>
  <g id="Heavy-L" transform="matrix(1 0 0 1 2558.39 1556)">
   <path d="(...)"/>
  </g>
  <g id="Bold-L" transform="matrix(1 0 0 1 2262.88 1556)">

We will not provide symbols other than #Regular-M so we have to remove the other ones.

TEMPLATE_ICON_SIZES = ["S", "M", "L"]
TEMPLATE_ICON_WEIGHTS = ["Black", "Heavy", "Bold", "Semibold", "Medium", "Regular", "Light", "Thin", "Ultralight"]

# We are only providing "Regular-M", so remove the other shapes.
TEMPLATE_ICON_SIZES.each do |size|
  TEMPLATE_ICON_WEIGHTS.each do |weight|
    id = "#{weight}-#{size}"
    next if id == "Regular-M" # Only leave the mandatory shape.
    template_svg.at_css("##{id}").remove
  end
end

The #Notes group is mostly text to see in a vector graphics editor.

 <g id="Notes">
  <rect height="2200" id="artboard" style="fill:white;opacity:1" width="3300" x="0" y="0"/>
  <line id="" style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="292" y2="292"/>
  <text style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;font-weight:bold;" transform="matrix(1 0 0 1 263 322)">Weight/Scale Variations</text>
  <text style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;text-anchor:middle;" transform="matrix(1 0 0 1 559.711 322)">Ultralight</text>
  (...)
  <text id="template-version" style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;text-anchor:end;" transform="matrix(1 0 0 1 3036 1933)">Template v.2.0</text>
  (...)
 </g>

From its name, it looks like #Notes could be removed without any problem, but in fact if you read the official documentation properly, it tells you that the #template-version text node inside #Notes is important. If you remove it, the position of left and right margins and the horizontal position of the shape inside those margins will be ignored. It is also recommended to not remove #artboard. If you really want to remove all unneeded nodes, removing child nodes of #Notes that have no id or an empty one might be OK.

Below the #Notes group, there is a very important #Guides group.

 <g id="Guides">
  (...)
  <line id="Baseline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="696" y2="696"/>
  <line id="Capline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="625.541" y2="625.541"/>
  (...)
  <line id="Baseline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1126" y2="1126"/>
  <line id="Capline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1055.54" y2="1055.54"/>
  (...)
  <line id="left-margin" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1391.3" x2="1391.3" y1="1030.79" y2="1150.12"/>
  <line id="right-margin" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1508.39" x2="1508.39" y1="1030.79" y2="1150.12"/>
 </g>

We are only providing a Regular-M symbol, so its vertical position between #Baseline-M and #Capline-M, and its horizontal position between #left-margin and #right-margin, are important. So we want to get the position of each of those guides.

By the way, as symbols have been designed to be used in conjunction with text, capline and baseline are typography terms. That is why if you look at the image of the template I included above, on the left side you have an A to be used as a reference.

def get_guide_value(template_svg, axis, xml_id)
  guide_node = template_svg.at_css("##{xml_id}")
  raise "invalid axis" unless %i{x y}.include?(axis)
  val1 = guide_node["#{axis}1"]
  val2 = guide_node["#{axis}2"]
  if val1 == nil || val1 != val2
    raise "invalid #{xml_id} guide"
  end
  val1.to_f # Convert the value from string to float.
end

# Get the x1 (should be the same as x2) of the #left-margin node.
original_left_margin = get_guide_value(template_svg, :x, "left-margin")
# Get the x1 (should be the same as x2) of the #right-margin node.
original_right_margin = get_guide_value(template_svg, :x, "right-margin")
# Get the y1 (should be the same as y2) of the #Baseline-M node.
baseline_y = get_guide_value(template_svg, :y, "Baseline-M")
# Get the y1 (should be the same as y2) of the #Capline-M node.
capline_y = get_guide_value(template_svg, :y, "Capline-M")

We then load the SVG icon and check if it has the expected size.

# Load the SVG icon.
icon_svg = File.open(SOURCE_SVG_PATH) do |f|
    # To generate a better looking SVG, ignore whitespaces.
  Nokogiri::XML(f) { |config| config.noblanks }
end

# The SVGs provided by designers had a fixed size of 64x64, so all the calculations below are based on this.
# If we get an unexpected size, the program ends in error.
# The SVG specs allows to specify width and height in not only numbers, but also percents, so handling a wider range of SVG files would be more complicated.
if icon_svg.root["width"] != ICON_WIDTH.to_s ||
  icon_svg.root["height"] != ICON_HEIGHT.to_s ||
  icon_svg.root["viewBox"] != "0 0 #{ICON_WIDTH} #{ICON_HEIGHT}"
  raise "expected icon size of #{icon.source_svg_path} to be (#{ICON_WIDTH}, #{ICON_HEIGHT})"
end

We then have to scale the provided icon to match the template.

The position of the left and right margins depend on the symbol chosen in the SF Symbols app, but #Baseline-M and #Capline-M are always at the same position, so we scale based on the spacing between those 2 guides.

scale = ((baseline_y - capline_y).abs / ICON_HEIGHT) * ADDITIONAL_SCALING
horizontal_center = (original_left_margin + original_right_margin) / 2

scaled_width = ICON_WIDTH * scale
scaled_height = ICON_HEIGHT * scale

# If you use the template's margins as-is, the generated symbol's width will depend on the template chosen.
# To not have to care about the template, we move the margin based on the computed symbol size.
horizontal_margin_to_center = scaled_width / 2 + MARGIN_LINE_WIDTH + ADDITIONAL_HORIZONTAL_MARGIN
adjusted_left_margin = horizontal_center - horizontal_margin_to_center
adjusted_right_margin = horizontal_center + horizontal_margin_to_center
left_margin_node = template_svg.at_css("#left-margin")
left_margin_node["x1"] = adjusted_left_margin.to_s
left_margin_node["x2"] = adjusted_left_margin.to_s
right_margin_node = template_svg.at_css("#right-margin")
right_margin_node["x1"] = adjusted_right_margin.to_s
right_margin_node["x2"] = adjusted_right_margin.to_s

We finished all our calculations, so we then insert the loaded icon at the correct position and size in the adjusted template, and generate a complete symbol file.

# Make a copy of the modified template.
# In this script we generate only one symbol, but if we end up generating multiple symbols at one it's safer to work on a copy.
symbol_svg = template_svg.dup

# It's finally time to handle that important #Regular-M node.
regular_m_node = symbol_svg.at_css("#Regular-M")

# Move the shape so its center is at the center of the guides.
translation_x = horizontal_center - scaled_width / 2
translation_y = (baseline_y + capline_y) / 2 - scaled_height / 2
# Prepare a transformation matrix from the values calculated above.
transform_matrix = [
  scale, 0,
  0, scale,
  translation_x, translation_y,
].map {|x| "%f" % x } # Convert numbers to strings.
regular_m_node["transform"] = "matrix(#{transform_matrix.join(" ")})"

# Replace the content of the #Regular-M node with the icon.
regular_m_node.children = icon_svg.root.children.dup

# Finish by writing the generated symbol to disk.
File.open(DESTINATION_SVG_PATH, "w") do |f|
  symbol_svg.write_to(f)
end

Problems that happened during implementation

Ending up with the code above required of course a lot of trial and error. Execute the script, check in a vector graphics editor and Xcode, update the script, and repeat. In the later stages, checking in Xcode was not only checking how the symbol appeared inside an asset catalog, but also trying to use the symbol in an Xcode project.

One problem that happened when I tried generating symbols from different provided SVGs, in some generated symbol files, on the side of the main shape there was some other shape. Looking a bit more at it, in some of the provided SVGs, there was a shape outside of the (0, 0, 64, 64) frame. In the source SVGs, viewport being 0 0 64 64 hid everything outside, so nobody realized that some other shape was left outside the frame. After I pointed it out the designers kindly removed those.

Another problem that I mentioned above in the implementation explanation, I first mistakenly thought #Notes could be freely removed. But if you remove that node, where you put the shape horizontally between the left and right margin, and the width between left and right margins, seem to have no effect on the generated symbol. After fixing the code to keep #Notes's children nodes with an "id" attribute (especially the #template-version node), the behavior matched my expectations.

Good and bad of fixed width

The symbols generated with the script above can be used without problem once added in an asset catalog. However, all the generated symbols have the same width, and that has its goods and bads. Even if our shapes here all fit in the same (0, 0, 64, 64) frame, the shapes themselves have different widths: the whitespace on the left and right inside that frame change depending on the symbol. iOS's custom symbols can use a different width for each symbol, and in Apple's SF Symbol multiple widths are used. The main reasons I went for a fixed width are the following.

  • When placing multiple symbols inside the same screen, having them all have the same width simplifies the layout.
  • Analyzing the shapes and calculating their real width is more complicated, and requires more hand-checking of the generated symbols. Also, if you start on that path, there's the problem that the real size of a shape and the size your eyes see (optical size) tend to be a bit different, and you start to want to be able to adjust sizes and margins per symbol.

What you choose depends on your use case, but I decided to go simple.

By the way, the width is the same for all symbols, but for some reason images generated from them have a width varying by 0.5~1.0 pts depending on the symbol. iOS 14 seems better in that regard, but it does happen even on iOS 14. I guess if you want something pixel perfect, you should probably use pixel images rather than vector ones...

A bit more convenience

For simplicity, the script above only processes one SVG. The internal script I wrote is a bit more powerful.

Its source is not only one file, but all SVG files in a specific directory, and generates a full asset catalog (xcassets directory), and also a Swift enum with the list of all the symbols.

In fact the format of an asset catalog is very simple. Also, in asset catalogs, a folder with the "Provides Namespace" checkbox checked providing a namespace is pretty convenient.

The generated enum looks like the following.

public enum CookpadSymbol: String, CaseIterable {
    public enum Package {
        // Namespace where the custom symbols are in the asset catalog
        public static let namespace = "cookpad"
        public static let version = "2.0.0"
    }

    case access
    case clip
    case clipAdd = "clip_add"
    case clipAdded = "clip_added"
    case clipRemove = "clip_remove"
    case lock

    // Name inside the asset catalog
    public var imageName: String { "\(Package.namespace)/\(rawValue)" }
}

How to use custom symbols

It's nice to have generated those symbols, but once you added them to an asset catalog, how can you use them?

UIImageView

To display a custom symbol, you generally use a UIImageView.

let symbolIconView = UIImageView()
// CookpadSymbol.imageName is the name inside the asset catalog as declared in the enum above.
symbolIconView.image = UIImage(named: CookpadSymbol.lock.imageName, in: .main)
symbolIconView.tintColor = .red
symbolIconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 10)

The size is specified with preferredSymbolConfiguration. However, if you use UIImage.SymbolConfiguration(pointSize: 10), changes of Dynamic Type settings won't have an effect on the symbol size. To support Dynamic Type, you either use UIImage.SymbolConfiguration(textStyle:), or pass the font the font with the size you want to UIImage.SymbolConfiguration(font:).

let symbolConfiguration = UIImage.SymbolConfiguration(font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 10)))

Contrarily to UILabel, there is no adjustsFontForContentSizeCategory property to set to enable (or disable) automatic text size adjustment.

NSAttributedString

As an alternative to using UIImageView, you can put your symbol in an NSAttributedString and display it in a UILabel or UITextView.

let attributedText = NSMutableAttributedString()
let imageAttachment = NSTextAttachment()
imageAttachment.image = UIImage(named: CookpadSymbol.lock.imageName, in: .main)
attributedText.append(NSAttributedString(attachment: imageAttachment))
attributedText.append(NSAttributedString(string: " 非公開"))
label.attributedText = attributedText

You have to be careful that UILabel.attributedText works differently from UILabel.text, in that even if you set adjustsFontForContentSizeCategory to true, Dynamic Type settings changes will not be reflected on the font size when they happen.

UIImage

When you want to handle a custom symbol as a UIImage, you explicitly give a size to UIImage.SymbolConfiguration, and pass it to either UIImage(named:in:with:), or to UIImage.applyingSymbolConfiguration() (or UIImage.withConfiguration()).

let configuration = UIImage.SymbolConfiguration(pointSize: 12)
let symbolImage = UIImage(named: CookpadSymbol.lock.imageName, in: .main, with: configuration)

You can specify the color with UIImage.withTintColor().

let redSymbolImage = symbolImage?.withTintColor(.red)

Even if you specify a tintColor, if you put a UIImage generated from a custom symbol into a UIImageView, the UIImageView's tintColor will take precedence, so if you really want the image's color to take precedence you can do as follows.

let reallyRedSymbolImage = symbolImage?.withTintColor(.red, renderingMode: .alwaysOriginal)

SwiftUI

You can also easily use custom symbols with SwiftUI.

Image(CookpadSymbol.arrowRight.imageName, bundle: .main)
    .font(.caption)
    .foregroundColor(.green)

Helpers

Adding a few helper methods to the enum generated will make using custom symbols even easier. Here I'm always specifying .main for the Bundle, but you should set it accordingly to where your asset catalog is.

// UIKit
extension CookpadSymbol {
    public func makeImage(with configuration: UIImage.Configuration? = nil) -> UIImage? {
        UIImage(named: imageName, in: .main, with: configuration)
    }

    public func makeAttributedString(
        with configuration: UIImage.Configuration? = nil,
        tintColor: UIColor? = nil
    ) -> NSAttributedString {
        var image = makeImage(with: configuration)
        if let tintColor = tintColor {
            image = image?.withTintColor(tintColor)
        }
        let imageAttachment = NSTextAttachment()
        imageAttachment.image = image
        return NSAttributedString(attachment: imageAttachment)
    }
}

// SwiftUI
extension Image {
    public init(_ symbol: CookpadSymbol) {
        self.init(symbol.imageName, bundle: .main)
    }
}

SF Symbols

As a side note, the code above is for custom symbols, but if you change UIImage(named:in:) to UIImage(systemName:), you can use it with SF Symbols. Custom symbols are customized SF Symbols so it makes sense that their use is similar.

Interface Builder

Inside Interface Builder (the interface editor inside Xcode), in Image View properties, you can easily choose your custom symbol like any other asset catalog image. You can also easily specify the size (however you cannot pass a font that went through UIFontMetrics).

f:id:vincentisambart:20201228113122p:plain

Final words

The official guide to create custom symbols does not mention automating the process, but SVG can be easily checked in vector graphics editors and text editors, and the SVG provided by designers were simple and clean, so the automatic generation of custom symbols went pretty smoothly. In the future, having more general tools to handle custom symbols would make things even easier.

It has not been long since I started using custom symbols, so they might have some disadvantages I have not realized yet, but currently I find them pretty convenient, easy to use in many different places.

The main limitation of SF Symbols and custom symbols is them being only available on iOS 13 and above, but with time that should become less of a problem.

カスタムなSF SymbolsをSVGから自動生成する

(English version here)

明けましておめでとうございます。モバイル基盤部のヴァンサン(@vincentisambart)です。

最近Appleがアプリの画面で使えるシンボルSF Symbolsに力を入れています。SF SymbolsはAppleの用意してくれたシンボルだけではなく、自分の作ったカスタムシンボルも使えます。Appleの紹介しているカスタムシンボルを作るワークフローに従うと手間がかかるので、既存のSVGからカスタムシンボルを自動生成できないか挑戦してみました。

経緯

だいぶ前からiOSクックパッドアプリで色んな画面で使われている単色アイコンはCookpadSymbolsというシンボルのみのフォントが使われていましたが、数ヶ月前デザイナーからシンボルの運用をフォントファイルからSVGに変えたいという要望が挙がりました。

アイコンは元々SVGで作成されていましたが、変更を加える度にSVGや設定ファイルをウェブ上のツールに読み込ませてフォントを生成するステップを省きたかったそうです。今となってはSVGを直接使えるようになった場面が多いですし。

CookpadSymbolsはこんな感じです。

f:id:vincentisambart:20201228112920p:plain

iOSでは、SVGとして用意されたシンボルを使うには以下の3つの方法があるかと思います。

  1. サイズの決まったピクセル画像として使う(実質PNGに変換されたかのように)
  2. ベクターデータのまま画像として使う(Asset CatalogのPreserve Vector Data設定)
    • Xcode 12以上で直接SVGを使えるようになりましたが、iOS 12以下でベクターデータとして扱うにはSVGを事前にPDFに変換する必要があるようです。
  3. カスタムシンボルとして使う(カスタムシンボルは簡単に言いますと自分で用意したカスタムなSF Symbolsのことです)
    • iOS 13以上が必要です。

iOSクックパッドアプリでは、シンボルは今までフォント形式で扱っていて、同じシンボルは画面によって違うサイズで表示されるので、固定サイズ画像として扱うとなるとだいぶ不便になります。元がベクター画像なので簡単に様々なサイズを自動的に用意できるとはいえ。

最近AppleがSF Symbolsを大きくプッシュしているようですし、デザイナーの要望が挙がった当時すぐiOS 12のサポートを終了する予定だったので、方法3でやってみることにしました。もしもiOS 12のサポート終了が大幅に遅れる場合や、実装している途中で大きい問題が発生した場合、最悪方法2にフォールバックすれば良いでしょうし。

結局iOS 12のサポート終了が当初の予定より遅れましたが、方法3のままで進みました。どうやって実装したのか説明しようと思いますが、その前にカスタムシンボルをもう少し説明しておきましょう。

カスタムシンボルとは

カスタムシンボルを紹介するには、まずSF Symbolsの話をしなければいけません。SF SymbolsはiOS 13以上に使える機能で、iOS開発者がアプリで使えるシンボル(色んなサイズで使えるシンプルな単色アイコン)です。普通の固定サイズの画像ではなく、文字と一緒に使えるように設計されています:サイズはフォントのポイントサイズで指定しますし、配置はフォントのベースラインに合わせることができます。

Appleの用意してくれたSF SymbolsはSF Symbolsアプリで以下のようにリストを見たり検索したりできます。

f:id:vincentisambart:20201228112934p:plain

Appleの用意してくれたシンボルだけではなく、自分の用意したカスタムなシンボルも合わせて使えます。カスタムなシンボルを用意するには、公式ガイドに従うと、まず公式のSF Symbolsアプリで追加したいシンボルに一番近いシンボルを選んで、SVGとしてエキスポートします。そのSVGをベクター画像編集ソフト(Illustratorなど)で編集して、Xcodeで使えるシンボルを用意します。

クックパッド内で使われているCookpadSymbolsはシンボルが現状300個近くあります。1つずつ手動で編集するとしたら手間が大きいです。運用変更の主な経緯がデザイナーにとってもっと運用しやすくなるためでしたので、手動でやりたくありません。自動化はプログラマーの大事な役目ですし、SVGは結局XML なので、なんとかなると思って作業を始めました。

SVGをXcodeに読み込ませてみる

SVGは既にデザイナーによって用意されていました。因みにそのSVGはウェブでもAndroidでも使われています。用意されていたSVGの1つが以下の通りでした(中身を細かく理解する必要はありません)。

<svg height="64" width="64" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="12" r="8"/><path d="M52.7 50.941l-7.913-4.396-3.335-8.34-.642-8.994 3.257.723 1.517 6.826a3.504 3.504 0 004.176 2.658 3.5 3.5 0 002.658-4.176l-2-9a3.5 3.5 0 00-2.658-2.658l-9-2a3.416 3.416 0 00-1.276-.037c-.16-.022-.319-.047-.484-.047h-8c-.163 0-.32.031-.479.055a3.48 3.48 0 00-3.254 1.26l-7.163 8.953-4.679.781a3.501 3.501 0 001.15 6.904l6-1a3.513 3.513 0 002.158-1.266l4.18-5.225 1.346 6.279-6.126 8.752A3.503 3.503 0 0021.5 49v9a3.5 3.5 0 107 0v-7.896l5.322-7.604h1.808l3.12 7.801a3.51 3.51 0 001.55 1.76l9 5a3.5 3.5 0 004.759-1.36 3.5 3.5 0 00-1.359-4.76z"/></svg>

ウェブブラウザーやベクター画像編集ソフトに読み込ませてみると、以下のように表示されます。

f:id:vincentisambart:20201228112940p:plain

Xcode 12がSVGを読み込めるので、深く考えずにこのSVGをXcodeでAsset Catalogにドラッグ&ドロップしてみると以下のようになります。

f:id:vincentisambart:20201228113043p:plain

求めているものとだいぶ違います。用意されていたSVGが最適化されている(不要なものが省いてある)ように見えるので、その最適化のどこかがXcodeと相性が悪いのかなと思いました。中身をよく見ると、最適化されているように見えるとはいえ、path004.176のように、数字の冒頭に無駄に見える0がある箇所があるのが少し不自然に感じました。単なるテキスト(XML)ファイルなので、ネット上のSVGの仕様をチラ見してから、試しにテキストエディターですべての不自然な0の後にスペースを入れてみて(004.1760 0 4.176など)、改めてXcodeに読み込ませてみたら以下のようになりました。

f:id:vincentisambart:20201228113052p:plain

まだ完璧ではないが、だいぶよくなりました。やはりXcodeの使っているSVG読み込みコードのSVGの仕様の解釈が不完全なようです。

XcodeのSVGの解釈を自分で補うことにするとしたらSVGの仕様を細かく理解する必要が出てくるので、自分でやる前にやってくれるツールがないでしょうか。

デザイナーに用意されていたSVGのレポジトリをよく見てみたら、SVGはSVGOというツールを使って最適化されていたようです。そのツールの設定を調べてみたら、それらしいpathに関する設定がありました。既にあった設定ファイルsvgo.ymlの最後に以下の2行を足して、SVGOを実行してみたら、なんと用意されたどのSVGも無事にXcodeに読み込まれるようになりました。

  - convertPathData:
      # Xcode doesn't handle properly paths without spaces after flags
      noSpaceAfterFlags: false

f:id:vincentisambart:20201228113102p:plain

1つだけの設定変更で済んで良かったです。

SVGファイルが以前に比べてほんの少し大きくなりますが、プラットフォームごとに設定を変えるとしたら運用が大変なので、どのプラットフォームも上記の設定で最適化されたSVGを使うことにしました。

Xcodeが読み込めるSVGになったのは大事な第一歩ですが、SVGを普通の画像としてではなく、シンボルとして使いたいので、SVGを元にシンボルを用意する必要があります。

シンボルを用意

公式ガイドに従うと、シンボルの用意の第一歩がSF Symbolsアプリから既存のSF Symbolsをまずエキスポートすることです。一番シンプルそうなcircleをエキスポートすると、以下のようなSVGファイルが書き出されます。

f:id:vincentisambart:20201228113116p:plain

シンボルごとにサイズ3つ、ウェイト8つを用意できますし、全部用意できたら一番良いのでしょうが、公式ガイドを読むとRegular Medium(Regular-M)だけが必須です。ひとまずは必須のもののみを用意することにしました。図形の縮尺を変えるだけなら、他のサイズはあとで簡単にできそうですし。

シンボルの運用を楽にしたいので、SF Symbolsアプリからエキスポートしたテンプレートに既存のSVGの中身を入れるのはガイドの説明のように手動ではなく、スクリプトでやることにしました。僕にとって書きやすいからRubyで書きましたが、XMLを扱うライブラリがあれば、どの言語でも簡単にできると思います。以下のコードはシンプルにしてコメントを多めにしたので、Rubyが分からなくてもやっているこを問題なく追えると思います。コード内のセレクターはできるだけCSSセレクターを使っています(#abcdがXML内にidの値がabcdであるノードを示します)。

最初は前準備です。ライブラリを読み込んで、必要な定数を定義して、テンプレートを読み込みます。

require "nokogiri" # XMLライブラリを使います

# SF Symbolsアプリからエキスポートしたファイルへのパス
TEMPLATE_PATH = "path/to/circle.svg"
# 用意されたSVGへのパス
SOURCE_SVG_PATH = "icon.svg"
# 出力されるSVGへのパス
DESTINATION_SVG_PATH = "icon-symbol.svg"

# 期待されているアイコンサイズ
ICON_WIDTH = 64
ICON_HEIGHT = 64
# SF Symbolsに近いサイズになるために必要な倍率(色々試した結果これで良さそうでした)
ADDITIONAL_SCALING = 1.7
# SVG内の#left-marginと#right-marginの幅
MARGIN_LINE_WIDTH = 0.5
# 左右に足している余白
ADDITIONAL_HORIZONTAL_MARGIN = 4

# テンプレートを読み込みます
template_svg = File.open(TEMPLATE_PATH) do |f|
  # もっときれいなSVGを生成するために、ホワイトスペースを無視するように
  Nokogiri::XML(f) { |config| config.noblanks }
end

テンプレートが3つのグループ(#Notes, #Guides, #Symbols)に分かれているXML(SVG)です。

<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 149-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
       "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="3300" height="2200">
 <!--glyph: "uni100000.medium", point size: 100.000000, font version: "Version 16.0d18e1", template writer version: "8"-->
 <g id="Notes">
  (中略)
 </g>
 <g id="Guides">
  (中略)
 </g>
 <g id="Symbols">
  (中略)
 </g>
</svg>

#Symbolsグループにシンボルが以下のように入っています

 <g id="Symbols">
  <g id="Black-L" transform="matrix(1 0 0 1 2854.05 1556)">
   <path d="(中略)"/>
  </g>
  <g id="Heavy-L" transform="matrix(1 0 0 1 2558.39 1556)">
   <path d="(中略)"/>
  </g>
  <g id="Bold-L" transform="matrix(1 0 0 1 2262.88 1556)">

必須の#Regular-M以外のシンボルは用意しないので、消しておく必要があります。

TEMPLATE_ICON_SIZES = ["S", "M", "L"]
TEMPLATE_ICON_WEIGHTS = ["Black", "Heavy", "Bold", "Semibold", "Medium", "Regular", "Light", "Thin", "Ultralight"]

# "Regular-M"だけを入れるので、それ以外の図形を消します
TEMPLATE_ICON_SIZES.each do |size|
  TEMPLATE_ICON_WEIGHTS.each do |weight|
    id = "#{weight}-#{size}"
    next if id == "Regular-M" # 必須な図形だけを残します
    template_svg.at_css("##{id}").remove
  end
end

テンプレートの冒頭の#Notesグループが主にベクター画像編集ソフトで見るためにあるテキストです。

 <g id="Notes">
  <rect height="2200" id="artboard" style="fill:white;opacity:1" width="3300" x="0" y="0"/>
  <line id="" style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="292" y2="292"/>
  <text style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;font-weight:bold;" transform="matrix(1 0 0 1 263 322)">Weight/Scale Variations</text>
  <text style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;text-anchor:middle;" transform="matrix(1 0 0 1 559.711 322)">Ultralight</text>
  (中略)
  <text id="template-version" style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;text-anchor:end;" transform="matrix(1 0 0 1 3036 1933)">Template v.2.0</text>
  (中略)
 </g>

#Notesという名前だから消しても問題ないと最初は思いましたが、まるまる消してはいけません。実は公式ドキュメントをちゃんと読むと書いてありますが、#Notesの中に#template-versionという大事なテキストノードがあります。#template-versionノードを消してしまうと、シンボルSVG内の左右のマージンの位置やその中の図形の水平位置が無視されてしまいます。#artboardを消さないのも推奨されています。 余計なノードを消したいなら、#Notesの子ノードの中でidが空文字列な場合や存在しないノードだけが良いかと思います。

#Notesグループのすぐ下に大事な#Guidesグループがあります。

 <g id="Guides">
  (中略)
  <line id="Baseline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="696" y2="696"/>
  <line id="Capline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="625.541" y2="625.541"/>
  (中略)
  <line id="Baseline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1126" y2="1126"/>
  <line id="Capline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1055.54" y2="1055.54"/>
  (中略)
  <line id="left-margin" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1391.3" x2="1391.3" y1="1030.79" y2="1150.12"/>
  <line id="right-margin" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1508.39" x2="1508.39" y1="1030.79" y2="1150.12"/>
 </g>

Regular-Mシンボルだけを用意するので、そのシンボルの#Baseline-M#Capline-Mに対する垂直位置、#left-margin#right-marginに対する水平位置、が大事になります。それぞれのグループの位置を取得しておきます。

因みにシンボルが文字の横に置かれるように設計されているため、capline(キャップライン)もbaseline(ベースライン)もフォントに関する用語です。上記のテンプレートの画像を見ると、左側に参照用にAがあるのはそのためです。

def get_guide_value(template_svg, axis, xml_id)
  guide_node = template_svg.at_css("##{xml_id}")
  raise "invalid axis" unless %i{x y}.include?(axis)
  val1 = guide_node["#{axis}1"]
  val2 = guide_node["#{axis}2"]
  if val1 == nil || val1 != val2
    raise "invalid #{xml_id} guide"
  end
  val1.to_f
end

# #left-marginノードの"x1"の値("x2"と同じ値のはず)を取得
original_left_margin = get_guide_value(template_svg, :x, "left-margin")
# #right-marginノードの"x1"の値("x2"と同じ値のはず)を取得
original_right_margin = get_guide_value(template_svg, :x, "right-margin")
# #Baseline-Mノードの"y1"の値("y2"と同じ値のはず)を取得
baseline_y = get_guide_value(template_svg, :y, "Baseline-M")
# #Capline-Mノードの"y1"の値("y2"と同じ値のはず)を取得
capline_y = get_guide_value(template_svg, :y, "Capline-M")

SVGアイコンを読み込んで期待しているサイズなのか確認しておきます。

# アイコンのSVGを読み込みます。
icon_svg = File.open(SOURCE_SVG_PATH) do |f|
  # もっときれいなSVGを生成するために、ホワイトスペースを無視するように
  Nokogiri::XML(f) { |config| config.noblanks }
end

# デザイナーに用意されていたSVGはサイズが64x64固定でしたので、後の計算はそれを元に書かれています。
# 期待しているサイズでなければエラーで終了します。
# SVGのwidth/heightは数字だけではなく、パーセントとかも使えるので、もっと幅広いSVGに対応する場合、もっと複雑になります。
if icon_svg.root["width"] != ICON_WIDTH.to_s ||
  icon_svg.root["height"] != ICON_HEIGHT.to_s ||
  icon_svg.root["viewBox"] != "0 0 #{ICON_WIDTH} #{ICON_HEIGHT}"
  raise "expected icon size of #{icon.source_svg_path} to be (#{ICON_WIDTH}, #{ICON_HEIGHT})"
end

用意されたアイコンのサイズをAppleのテンプレートのサイズに合わせる必要があります。

SF Symbolsアプリからエキスポートされるテンプレートは選ばれたシンボルによって左右のマージンの位置が変わりますが、#Baseline-M#Capline-Mが固定なので、サイズを#Baseline-M#Capline-Mの間隔に合わせます。

scale = ((baseline_y - capline_y).abs / ICON_HEIGHT) * ADDITIONAL_SCALING
horizontal_center = (original_left_margin + original_right_margin) / 2

scaled_width = ICON_WIDTH * scale
scaled_height = ICON_HEIGHT * scale

# テンプレートのマージンをそのまま使う場合、出来上がったシンボルの幅が選んだテンプレートによって変わります。
# テンプレートを気にしたくないので、計算したシンボルのサイズを元に左右のマージンの位置を調整します。
horizontal_margin_to_center = scaled_width / 2 + MARGIN_LINE_WIDTH + ADDITIONAL_HORIZONTAL_MARGIN
adjusted_left_margin = horizontal_center - horizontal_margin_to_center
adjusted_right_margin = horizontal_center + horizontal_margin_to_center
left_margin_node = template_svg.at_css("#left-margin")
left_margin_node["x1"] = adjusted_left_margin.to_s
left_margin_node["x2"] = adjusted_left_margin.to_s
right_margin_node = template_svg.at_css("#right-margin")
right_margin_node["x1"] = adjusted_right_margin.to_s
right_margin_node["x2"] = adjusted_right_margin.to_s

全ての計算が終わったので、調整したテンプレートに読み込んだアイコンを正しい位置とサイズで入れてファイルを出力します。

# 元のテンプレートをコピーする。
# 今回シンボル1つしか生成しないが、一気にいくつものシンボルを生成する場合コピーを編集した方が安全です。
symbol_svg = template_svg.dup

# ついに肝心の#Regular-Mノードに手をつける時が来ました。
regular_m_node = symbol_svg.at_css("#Regular-M")

# 図形がガイドの中央になるよう移動させます。
translation_x = horizontal_center - scaled_width / 2
translation_y = (baseline_y + capline_y) / 2 - scaled_height / 2
# 上記に計算された移動や倍率を元に変換行列を用意します。
transform_matrix = [
  scale, 0,
  0, scale,
  translation_x, translation_y,
].map {|x| "%f" % x } # 文字列に変換
regular_m_node["transform"] = "matrix(#{transform_matrix.join(" ")})"

# #Regular-Mノードの中身を用意されていたアイコンに置き換えます。
regular_m_node.children = icon_svg.root.children.dup

# 最後に生成したシンボルを書き出します。
File.open(DESTINATION_SVG_PATH, "w") do |f|
  symbol_svg.write_to(f)
end

実装中に起きた問題

もちろん上記のコードが出来上がるまでは、スクリプトを実行して、ベクター画像編集ソフトやXcodeで確認して、スクリプトの修正する、の繰り返しでした。実装が進んでいたら、Xcodeでの確認はAsset Catalog内だけではなく、普通のXcodeプロジェクトに取り込んで使ってみるのも含んでいました。

問題の1つは、色んなSVGからシンボルを生成したら、一部の生成されたシンボルファイルに元の図形の横に別の図形がありました。よく見たら、用意されていたSVGの一部に(0, 0, 64, 64)枠の外に図形が入っていました。viewport0 0 64 64だったのでその外が見えていなくて誰も気づいていませんでした。デザイナーにその枠外図形を消してもらいました。

もう1つは実装の説明でも書きましたが、#Notesノードが要らないだろうと思って消してしまったが間違いでした。それで図形を左右マージンの間にどこに置いても(中央寄りでも左寄りでも右寄りでも)、左右マージンをもっと幅広くしても、生成されたシンボルが変わりませんでした。#Notesに入っている#template-versionが残るように修正することで期待通りに動くようになりました。

幅固定のよしあし

上記のスクリプトで生成されたシンボルはAsset Catalogを入れて問題なく使えます。ただし、幅をすべてのシンボル共通にしましたが、それに良し悪しがあります。枠の幅が共通でも、その中の図形自体の幅がそれぞれなので、左右の余白がバラバラです。iOSのカスタムシンボルはシンボルごとに幅を変えられますし、実際Appleの用意したSF Symbolsの幅が様々です。そうした主な理由が2つあります。

  • いくつかのシンボルを同じ画面内で配置する場合、幅が共通だった方が配置しやすいと思います。
  • 図形の形を解析して本当の幅を計算するのとなると複雑になりますし、もっと細かく確認する必要があるからです。また、その道を歩み始めると、本当のサイズと目に見えるサイズ(光学的サイズ)がちょっと違ったりしますし、シンボルによって微調整したくなったりします。

どうするのかユースケースによると思いますが、シンプルでいくことにしました。

因みに幅を共通にしましたが、なぜかコードでシンボルから生成された画像はシンボルによって幅に0.5~1.0 ptの差があります。iOS 13よりiOS 14の方がましのようだけど、iOS 14でも起きています。まぁpixel perfectを求めるなら、ベクター画像ではなく、ピクセル画像を用意することですね。

もう少し便利に

上記のスクリプトは分かりやすさのためSVG 1つだけを生成するものです。社内で用意したスクリプトはそれより少し強力です。

元はファイル1つではなく、特定なディレクトリーのすべてのSVGファイルを処理していきますし、生成しているのはSVGだけではなく、Asset Catalog(xcassetsディレクトリー)を丸々生成していますし、シンボルのリストのSwift enumのコードも生成しています。

Asset Catalogは形式がとても簡単です。Asset Catalogはフォルダーの「Provides Namespace」にチェックを入れるとその中身がネームスペースに入るので便利です。

以下のような enum のコードを生成しています。

public enum CookpadSymbol: String, CaseIterable {
    public enum Package {
        // Asset Catalogにカスタムシンボルを入れたネームスペース
        public static let namespace = "cookpad"
        public static let version = "2.0.0"
    }

    case access
    case clip
    case clipAdd = "clip_add"
    case clipAdded = "clip_added"
    case clipRemove = "clip_remove"
    case lock

    // Asset Catalog内の名前
    public var imageName: String { "\(Package.namespace)/\(rawValue)" }
}

カスタムシンボルの使い方

シンボルを生成したのは良いが、Asset Catalogに入れたらアプリ内でどうやって使えるのでしょうか。

UIImageView

カスタムシンボルを表示するには基本的にUIImageViewを使います。

let symbolIconView = UIImageView()
// CookpadSymbol.imageNameが上記にenumに定義されたAsset Catalog内の名前です。
symbolIconView.image = UIImage(named: CookpadSymbol.lock.imageName, in: .main)
symbolIconView.tintColor = .red
symbolIconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 10)

preferredSymbolConfigurationでサイズが決まります。ただし、UIImage.SymbolConfiguration(pointSize: 10)を使うとDynamic Type設定の変更が反映されません。Dynamic Type対応が必要な場合、UIImage.SymbolConfiguration(textStyle:)を使うか、Dynamic Typeの設定によってサイズを変えるフォントをUIImage.SymbolConfiguration(font:)に渡すかです。

let symbolConfiguration = UIImage.SymbolConfiguration(font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 10)))

UILabelと違ってadjustsFontForContentSizeCategoryのように別途に設定する必要あるプロパティがありません。

NSAttributedString

UIImageViewの他に、NSAttributedStringに入れて、UILabelUITextViewでも表示できます。

let attributedText = NSMutableAttributedString()
let imageAttachment = NSTextAttachment()
imageAttachment.image = UIImage(named: CookpadSymbol.lock.imageName, in: .main)
attributedText.append(NSAttributedString(attachment: imageAttachment))
attributedText.append(NSAttributedString(string: " 非公開"))
label.attributedText = attributedText

UILabel.attributedTextの懸念点はUILabel.textと違って、adjustsFontForContentSizeCategorytrueにしても、Dynamic Typeの設定変更がすぐ反映されないところです。

UIImage

カスタムシンボルをUIImageとして扱いたい場合、サイズをUIImage.SymbolConfigurationで明記して、UIImage(named:in:with:)に渡すか、UIImage.applyingSymbolConfiguration()(またはUIImage.withConfiguration())に渡すかです。

let configuration = UIImage.SymbolConfiguration(pointSize: 12)
let symbolImage = UIImage(named: CookpadSymbol.lock.imageName, in: .main, with: configuration)

色の指定はUIImage.withTintColor()を使います。

let redSymbolImage = symbolImage?.withTintColor(.red)

tintColorを指定しても、シンボルから作成したUIImageUIImageViewに入れるとき、UIImageViewtintColorが優先されるので、どうしても画像自体の色を優先させたい場合は以下のようにできます。

let reallyRedSymbolImage = symbolImage?.withTintColor(.red, renderingMode: .alwaysOriginal)

SwiftUI

SwiftUIでも簡単に使えます。

Image(CookpadSymbol.arrowRight.imageName, bundle: .main)
    .font(.caption)
    .foregroundColor(.green)

ヘルパー

生成されたenumにいくつかのヘルパーを用意するとさらに使いやすくなります。ここでBundleは固定で.mainを渡していますが、自分のユースケースに合わせてください。

// UIKit
extension CookpadSymbol {
    public func makeImage(with configuration: UIImage.Configuration? = nil) -> UIImage? {
        UIImage(named: imageName, in: .main, with: configuration)
    }

    public func makeAttributedString(
        with configuration: UIImage.Configuration? = nil,
        tintColor: UIColor? = nil
    ) -> NSAttributedString {
        var image = makeImage(with: configuration)
        if let tintColor = tintColor {
            image = image?.withTintColor(tintColor)
        }
        let imageAttachment = NSTextAttachment()
        imageAttachment.image = image
        return NSAttributedString(attachment: imageAttachment)
    }
}

// SwiftUI
extension Image {
    public init(_ symbol: CookpadSymbol) {
        self.init(symbol.imageName, bundle: .main)
    }
}

SF Symbols

余談ですが、上記のコードがカスタムシンボルのためですが、UIImage(named:in:)UIImage(systemName:)に変えると、SF Symbolsで使えます。カスタムシンボルがカスタマイズされたSF Symbolsなので、使い方が近いのは自然かと思います。

Interface Builder

Interface Builder(Xcode内インターフェースエディター)内でImage ViewのプロパティでAsset Catalogのように簡単にカスタムシンボルを選ぶことができますし、コードのようにサイズを簡単に選べます(ただしUIFontMetricsを通ったフォントは渡せません)。

f:id:vincentisambart:20201228113122p:plain

やってみてどうだった

カスタムシンボルの作り方の公式ガイドに自動化に関する話はありませんでしたが、SVGはベクター画像編集ソフトでもテキストエディターでも確認できるファイル形式ですし、デザイナーが用意してくれていたSVGがきれいでシンプルでしたので、カスタムシンボルの生成は割りとスムーズにできたと思います。今後もっと幅広く使えるカスタムシンボルを扱うツールが増えたらさらに楽になるかと思います。

カスタムシンボルを使い始めてから時間がまだあまり経っていないので、今後気づく懸念点は出てくるかもれませんが、いまのところ簡単に色んな場面で使えて便利です。

SF SymbolsもカスタムシンボルもiOS 13以上を必要としているのは一番の懸念点だと思いますが、時間が解決してくれます。

データ基盤チーム0人で運用は回るのか?! 前人未踏チャレンジ・クックパッドデータ基盤のすべて2020

技術部データ基盤グループの青木です。

ここ1、2年はなぜか成り行きでBFFをでっちあげたり、 成り行きでiOSアプリリニューアルのPMをしたりしていたので あまりデータ基盤の仕事をしていなかったのですが、 今年は久しぶりに本業に戻れたのでその話をします。

突然の1人チーム、そして0人へ……

今年のデータ基盤チームは消滅の危機から始まりました。

間違いなく去年末は5人のチームだったと思うのですが、 メンバーがイギリスへグローバルのデータ基盤チームを作りに行ったり、 山へ検索システムを直しに行ったり、川へレシピ事業の分析業務をやりに行ったり、 海へ広告のエンジニアリングをしに行ったりするのをホイホイと気前よく全部聞いていたら、 なんと4月から1人だけのチームになってしまいました。

事はそれで終わりません。 恐ろしいことに10月にはわたし自身も育休に入ることになったので、 10月はデータ基盤が0人になることが決まりました。

えっ……マジで……? ヤバない……?

もちろん大変ヤバいです。そんなわけで今年は徹底的な運用改善、 できれば完全無人運用が可能なシステムが最優先目標になりました。

アーキテクチャの概要

まずは前提として、クックパッドのデータ基盤アーキテクチャをざっくり説明しておきます。

f:id:mineroaoki:20201229002438p:plain
クックパッドのデータ基盤アーキテクチャ

中心とするデータベースはAmazon Redshiftです。 2016年から同じサイズのクラスターを使い続けています。

データインポートはマスター、ログ、それ以外の3系統。 各種アプリケーションのマスターテーブルは内製のPipelined Migratorまたは AWS DMS(Data Migration Service)で取り込んでいます。 MySQLがmigrator、PostgreSQLがDMSという使い分けです。 ログにはRedshift Spectrumを使っており、Spectrumへのロードにはこれまた内製の Prismというシステムを使っています。 それ以外のSaaSやDynamoDBのデータについては、アドホックなバッチジョブを Bricolageフレームワークで作ってロードしています。

Redshift内での処理はBricolageを使ったSQLバッチが大半です。 ごく一部はUDFを使ったり他システムへ処理を投げたりしていますが、 9割以上はpure SQLで処理しています。

一方のデータエクスポートも3系統あります。 管理画面などの社内アプリケーション、BIツール(社内標準はTableau)、 それに他システムへのバルクエクスポートです。

バルクエクスポートについてのみ詳細を説明すると、 基本的にはQueuery(きゅーり)という内製のシステムを使っています。 QueueryはHTTPのAPIでRedshiftにクエリーを投げられる薄いシステムで、 内部ではRedshiftのUNLOADを使っています。 アプリケーションはUNLOADされたデータをS3から読むので、 読み込みの負荷をRedshiftから切り離すことができる利点があります。

特にRubyからは、redshift-connectorというライブラリで Queueryを簡単に使えるようにしています。

2020年に行った施策

以上がデータ基盤の概要です。

アーキテクチャは最初に設計した2016年からほとんど変わっていませんが、 5年たったので細部の実装はいろいろと変わってきています。 2020年はさきほど述べたように運用改善が最優先だったので、 そのあたりを中心に対応しました。以下の5本立てでお送りします。

  1. Redshift Spectrumへの移行が(だいたい)完了
  2. Prismの運用改善
  3. ログ定義からのクライアント自動生成
  4. Redshiftのワークロード管理機能の活用
  5. Tableau運用フローの改善

1. Redshift Spectrumへの移行が(だいたい)完了

今年最大の成果はなんと言ってもSpectrum化が「だいたい」終わったことです。

Spectrum化作業はDWHチームのべ5人で交代しながらチマチマやっていたせいで、実に丸3年かかりました。 Redshiftの内部ディスクにあったログテーブルを300本近く捨てたことで、 クラスターのディスク容量は50%を切りました。これまでは常時カツカツで、 80%を越えるたびに過去のデータを消しては凌いでいたことを思うと隔世の感があります。

実際にやったことは1000本近いバッチジョブをひたすら書き換えるだけの簡単なお仕事です。 書き換えては数値検証、書き換えては数値検証で、検証の時間が一番長かったですね。 横長スプレッドシートと仲良くなれます。

また、移行が完了したことで、内部テーブルにロードするために使っていた 旧システム(strload v2, v3)を捨てられるようになりました。 これでようやくロードシステム3系統をメンテする地獄から解放されます。

2. Prismの運用改善

Spectrum化を進めていくうえで大きな課題になってきたのがPrismの運用のつらさです。

今年はコロナの影響などもあって、3月〜4月ごろにやたらとログの流量が増えており、 しょっちゅうPrismマージジョブのメモリが溢れて死に続ける事故が起きていました。 しかし、その当時のPrismはモニタリングするにもDBを直接見るしかなく、 ジョブのログテーブルもなかったので、ジョブが死んでも何の処理中に死んだのかもよくわからない有様……。

これではさすがにやってられないので、 Prismの前に使っていたstrloadというロードシステムの管理画面を流用し、 2日くらいでコンソールをでっちあげました。

f:id:mineroaoki:20201229002600p:plain
Prismの管理画面

もっとも、管理画面を作ったところで現状が見えるようになっただけにすぎません。 根本的に問題を解決した施策は、遅延ログの扱いを変更したことでした。

これまでPrismはどんなに遅れて到着したログもすべて受け入れて既存パーティションへ マージしていたのですが、今年からはそれを14日で捨てるように変えました。 これはBigQueryもそういう仕様ですし、問題はなかろうということである日突然えいやっと切り替えました。

この点は開発前にはよくわかっていなかったところの1つなのですが、 遅れたログを永久に受け入れていると、ロードシステムの負荷が非常に大きいのです。

例えばプッシュ通知を配信したときに、その処理のためにアプリがバックグラウンドで動く場合があります。 すると端末側のログバッファがいっせいにフラッシュされ、 しばらく休眠していたユーザーも含めて過去のログがまとめて到着します。 すると結果として「プッシュ通知を送るたびに全ログ全期間をマージしなおす」という事態に陥ってしまうわけです。 これは負荷の面でも、コストの面でもさすがに看過できません。

遅延ログを14日で切るようにしたらPrismマージジョブの数が激減して(下図)、 いきなりすべてが安定しました。

f:id:mineroaoki:20201229002627p:plain
6/15から山岳地帯がサバンナに激変

ちなみに、Prismもだいぶ安定してきたので、来年は残りの懸念を潰してオープンソース化するつもりです。

3. ログ定義からのクライアントコード自動生成

今年はiOSアプリのリニューアルという大きな動きがあったので、 そのどさくさに紛れて新しいログの仕組み、通称「大統一アクティビティログ」を導入してもらいました。 この仕組みを使うと、特定のMarkdown形式でログのイベントを定義しておくことで クライアントのロガーとログ定義が自動生成されて、型のズレを根絶することができます。 詳しくは id:giginet の記事「ドキュメントベースの型安全なモバイルアプリ行動ログ基盤の構築」を参照してください。

この仕組みは本当によくできていて、リリース以前にログをちゃんと考える契機になるうえ、 自動的にログのドキュメントが整備されるようになっています。 さらに自動生成システムはAndroidアプリやウェブでもそのまま再利用できたため、 コスパも非常によかったです。

データ基盤側の視点では、この仕組みを導入したことによって、 ログの型が事前に決まるようになった点が最大の利点でしょう。

これまではまず最初にアプリでログ出力が実装されて、 実際にログが届き始めてからログの定義(型)をもらい、 両者のズレをなんとかするというフローでログを運用していました。 しかし当然ながらこの順序では、想定通りのログが出ていなかったり、 データ基盤に設定するログ定義を間違えてしまうことが頻繁に起きます。 するとデータ基盤側でもデータを入れ直すタスクが発生して、 そのたびにいちいち手作業で対応していたわけです。 大統一ログの導入後はこのようなミスマッチも手作業も、いっさいなくなりました。

さて、定義が自動生成されるようになったので、このさい設定の適用も自動化しようということで、 ログ定義をGitHubにコミットしたら自動的に定義を本番適用するツールを開発しました。 このツールができたことによって、ログを追加するには次の3ステップで済むようになりました。

  1. 特定のMarkdown形式でログのイベントを定義する
  2. クライアントのロガーとログ定義を自動生成する
  3. ログ定義を専用のレポジトリにPull Requestしてマージする

このステップはすべて各アプリケーションの開発者が自分で行うことができます。

実はログ定義の本番適用とその後のフォローは、 データ基盤で発生する定期作業の中でも最も頻度が高い作業でした。 この作業を自動化できたことがデータ基盤0人期間を乗り切るための決定打となってくれました。

4. Redshiftのワークロード管理機能の活用

Redshift上のワークロードに関しては、 Concurrency ScalingとUsage Limit、それにAutoWLMを有効にして、 日々襲来する負荷の波をやりすごすことに取り組みました。

Concurrency Scalingは、Redshiftのread onlyクラスターを一時的に増やして、 クエリーの処理キャパシティを上げる機能です。これには当然ながら(?)お金がかかるのですが、 1日1時間の無料枠があるため、1時間だけスケールさせておけば無料で使えます。 そして1時間でスケールを止めるためにUsage Limitを使います。

f:id:mineroaoki:20201229002652p:plain
Concurrency ScalingとUsage Limit

Concurrency ScalingとUsage Limitについてはチームメンバーが書いた記事(英語ですが)があるので、 詳細を読みたいかたはぜひそちらをご参照ください。 結果だけざっくり言うと、コミット待ちの時間が約15%減りました。

最後のAutoWLMはこれまでのManual WLMと違い、CPUとI/Oも配分できるところが特徴です。 Manual WLMではせいぜいメモリしか配分できなかったので、ようやく普通のWLMになったなという印象です。

AutoWLMの設定にはあまりこっていません。 短かそうなクエリーにはリソースを多めに与えて速攻で終わらせる、 長い時間動いているクエリーは徐々にペナルティを増やしてリソース割り当てを減らす、この2つだけです。

様々なクエリーが混在する混合ワークロード環境では、短かいクエリーを早く終わらせることが肝要です。 何も知らないとついうっかり重いクエリーにリソースを割り当てたくなるのですが、実はそれが最もよくありません。 重いクエリーにリソースを割り当てると、大量のリソースがずっと占有されることになり、結果としてすべてのクエリーが詰まります。 むしろ短いクエリーに多すぎるくらいにリソースを割り当てて、とっとと次のクエリーが入る場所を空けさせたほうがパフォーマンスは上がります。

もっともAutoWLMを適用した効果は正直よくわからず、数値では明確に出せませんでした。 体感だとなんとなく待たされることが減っている気がしますが、プラシーボかもしれません。

5. Tableau運用フローの改善

いまのところ、クックパッドでは次のようにTableauワークブックの標準運用を定めています。

  1. 最初はカスタムクエリー(ワークブック埋め込みのSQL)を使って手軽に作る。データソースは抽出にする。
  2. カスタムクエリーをRedshiftのビューに変換する。
  3. ビューが重くなったら蓄積バッチ化する。

Tableauのカスタムクエリーは、作るときには簡単ではあるものの、 一度Serverにアップロードしてしまうと、ワークブックをダウンロードして 開かないと見ることができません。またそのときにRedshiftユーザー名を データソースの個数だけ要求されたりするので使い勝手が最悪に近いです。 できるだけ早くビューにしてしまって、 Tableauワークブックではビューをselectするだけにすべきでしょう。

しかしビューにするためにも結局カスタムクエリーを見る必要があり、 そのためにまたワークブックをダウンロードして20回ユーザー名を入力しなければいけないわけです。 これはあまりにもアホくさいですし、誰もやってくれないので、 カスタムクエリーをS3にダンプする日次バッチを作りました。

またRedshiftのビューを更新するにはバッチユーザーの権限が必要なので、 手作業でやるとわたしがボトルネックになりますし、 手更新はチームでのレビューがやりにくいという問題もあります。 そこで、ビュー定義をGitHubにコミットしたら自動的にビューを作成・更新する仕組みを作りました。 これはほぼ同じ事例をネットで見かけたので、やはりどこも同じことを考えるものですね。

結果、0人期間は乗り越えられたのか?

以上が今年やってきたことです。 ほぼ全方位にわたってとにかく手作業を減らし、そもそも定期的なメンテ作業が発生しないようにすること、 開発者にセルフサービスで問題を解決してもらえるようにすることに注力しました。

結論を言えば、データ基盤0人期間はなんとか乗り切ることができました。 実際には2、3回ちょっとした問題が起きたのですが、 元データ基盤のメンバーたちが首尾よく解決してくれたのでノーカンです。 完全無風とはいきませんでしたが、乗り切れたのでよしとしましょう。 元メンバーのみんなには感謝です! 今度おごります。

アーキテクチャ選定で後悔していることと、していないこと

ところで、この年末でクックパッドのデータ基盤は開発開始からほぼ丸5年を迎えます。 この節目に、アーキテクチャの選択について後悔していることと、していないことを総括したいと思います。

後悔していないこと

まず、Redshiftを選んだことは後悔していません。

一時期はBigQueryがうらやましすぎて、 他社のデータ基盤の人にBigQueryの話を聞くたびに 「ソーダヨネービッグクエリーベンリダヨネースゴイスゴーイ」 を無表情で連呼する機械と化していましたが、 まあいまでもうらやましい点もあるんですが、 トータルで見れば現状は悪くないなと思うようになりました。 あと5年戦って10年までいけそうな気がしています。

第一に、クックパッド全社のアーキテクチャを見たときにRedshiftは最もシンプルかつ安価なソリューションです。 やはりアプリケーションとデータ基盤をAWSで統一できるという点は非常に大きいと思います。 人間が分析をするだけならばデータ基盤を外出しにしてもたいして問題はないと考えていますが、 他システムとのやりとりが増えてくると、認証の複雑化なども含むデータ移動のコストがばかになりません。 これからますますデータ基盤と他システムとのデータ連携パスが増える一方であることを考えると、 アプリケーションとデータはできるかぎり近くの、連携が容易な場所に置くべきでしょう。

第二に、SpectrumやPartiQLによって、 ログをRedshiftで扱いやすくなったことが挙げられます。 もし仮にこれらの新機能がないままだったら、さすがに後悔していたでしょう。 Redshiftは次々に新機能がリリースされるので、いま困っていることでも 少し待っていたらどうにかなるのではないかという謎の安心感があります。

最後に、Redshift Federated Queryの存在が挙げられます。 Federated QueryはアプリケーションのDB(MySQLやPostgreSQL)に Redshiftから直接接続してクエリーすることができる機能です。 これはAWSでシステムを統一してこそ活用できる機能なので、 クックパッドにとってはまさに狙い通り、待望の機能でした。 今年はPostgreSQL限定だったので試験運用にとどまりましたが、 来年はいよいよMySQLサポートがやってくるので、大々的に使っていくつもりです。

来年はRA3ノードも導入する予定ですし、まだまだRedshift周辺は楽しめそうです。

後悔していること

逆に最も後悔した選択はTableauです。

この記事でもTableauのカスタムクエリーをダンプする仕組みなどについて述べましたが、 そもそもこれはTableauのダメなところをカバーする仕組みであって、 こんなロクでもない機能を実装しなければいけない時点でもうダメです。

運用面では、共有に向いていないデータソースの仕組みと、 抽出(extract)更新の管理機能が弱すぎる点が癌です。 利用者側から見るとコラボレーションと共有の機能が貧弱すぎます。 総じてTableau Serverの機能不足が目立ちますね。

ちなみに、以前に利用していたRedashは手軽さは最高によかったのですが、 データ更新ジョブの実行ログが貧弱である(というかない)こと、 クエリーの並列実行でキューが壊れまくることが課題でした。 いったい何回Redis(クエリー実行キューがある)をflushdbしたかわかりません。

データ基盤チームは仲間を募集しています

さすがに1人チームは無理があるということがわかったので、来年からはメンバーが1人増えることになっています。 もう1人くらいは社内から増やせそうな気がしますが、 できればさらにもう1人ほしいので、データ基盤チームでは仲間を大募集しています。 データ基盤を整備するどさくさに紛れて新しいシステムを開発したい人はぜひご応募ください。 以下のページの「データエンジニア」がデータ基盤チームです。

クックパッド採用情報 https://info.cookpad.com/careers/jobs/?jobs=engineer

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/