This question already has answers here:
How to clear font cache filled with emoji characters?
(6 answers)
Closed 7 years ago.
My current assignment is a iOS keyboard extension, which among other things offers all iOS-supported Emoji's (yes, I know iOS has a builtin Emoji keyboard, but the goal is to have one included in the keyboard extension).
For this Emoji Layout, which is basically supposed to be a scroll view with all emojis in it in a grid order, I decided to use an UICollectionView, as it only creates a limited number of cells and reuses them. (There are quite a lot of emojis, over 1'000.) These cells simply contain a UILabel, which holds the emoji as its text, with a GestureRecognizer to insert the tapped Emoji.
However, as I scroll through the list, I can see the memory usage exploding for somewhere around 16-18MB to over 33MB. While this doesn't trigger a memory warning on my iPhone 5s yet, it may as well on other devices, as app extensions are only dedicated a very sparse amount of resources.
EDIT: Sometimes I do receive a memory warning, mostly when switching back to the 'normal' keyboard layout. Most times, the memory usage drops below 20MB when switching back, but not always.
How can I reduce the amount of memory used by this Emoji Layout?
class EmojiView: UICollectionViewCell {
//...
override init(frame: CGRect) {
super.init(frame: frame)
self.userInteractionEnabled = true
let l = UILabel(frame: self.contentView.frame)
l.textAlignment = .Center
self.contentView.addSubview(l)
let tapper = UITapGestureRecognizer(target: self, action: "tap:")
self.addGestureRecognizer(tapper)
}
override func prepareForReuse() {
super.prepareForReuse()
//We know that there only is one subview of type UILabel
(self.contentView.subviews[0] as! UILabel).text = nil
}
}
//...
class EmojiViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout {
//...
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
//The reuse id "emojiCell" is registered in the view's init.
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("emojiCell", forIndexPath: indexPath)
//Get recently used emojis
if indexPath.section == 0 {
(cell.contentView.subviews[0] as! UILabel).text = recent.keys[recent.startIndex.advancedBy(indexPath.item)]
//Get emoji from full, hardcoded list
} else if indexPath.section == 1 {
(cell.contentView.subviews[0] as! UILabel).text = emojiList[indexPath.item]
}
return cell
}
//Two sections: recently used and complete list
override func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
return 2
}
}
let emojiList: [String] = [
"\u{1F600}",
"\u{1F601}",
"\u{1F602}",
//...
// I can't loop over a range, there are
// unused values and gaps in between.
]
Please let me know if you need more code and/or information.
Edit: My guess is that iOS keeps the rendered emojis somewhere in the memory, despite setting the text to nil before reuse. But I may be completely wrong...
EDIT: As suggested by JasonNam, I ran the keyboard using Xcode's Leaks tool. There I noticed two things:
VM: CoreAnimation goes up to about 6-7MB when scrolling, but I guess this may be normal when scrolling through a collection view.
Malloc 16.00KB, starting at a value in the kilobytes, shoots up to 17MB when scrolling through the whole list, so there is a lot of memory being allocated, but I can't see anything else actually using it.
But no leaks were reported.
EDIT2: I just checked with CFGetRetainCount (which still works when using ARC) that the String objects do not have any references left once the nil value in prepareForReuse is set.
I'm testing on an iPhone 5s with iOS 9.2, but the problem also appears in the simulator using a iPhone 6s Plus.
EDIT3: Someone had the exact same problem here, but due to the strange title, I didn't find it up to now. It seems the only solution is to use UIImageViews with UIImages in the list, as UIImages in UICollectionView's are properly released on cell reuse.
it's pretty interesting, in my testing project, i commented out the prepareForReuse part in the EmojiView, and the memory usage became steady, project started at 19MB and never goes above 21MB, the (self.contentView.subviews[0] as! UILabel).text = nil is causing the issues in my test.
I think you don't use storyboard to design the collection view. I searched around and found out that you need to register the class with identifier before you populate the collection view cell. Try to call the following method on viewDidLoad or something.
collectionView.registerClass(UICollectionViewCell.self , forCellWithReuseIdentifier: "emojiCell")
Since you have memory issues you should try lazy loading your labels.
// Define an emojiLabel property in EmojiView.h
var emojiLabel: UILabel!
// Lazy load your views in your EmojiView.m
lazy var emojiLabel: UILabel = {
var tempLabel: UIImageView = UILabel(frame: self.contentView.frame)
tempLabel.textAlignment = .Center
tempLabel.userInteractionEnabled = true
contentView.addSubview(tempLabel)
return tempLabel;
}()
override func prepareForReuse() {
super.prepareForReuse()
emojiLabel.removeFromSuperview()
emojiLabel = nil
}
//...
class EmojiViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout {
//...
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
//The reuse id "emojiCell" is registered in the view's init.
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("emojiCell", forIndexPath: indexPath) as! EmojiView
//Get recently used emojis
if indexPath.section == 0 {
cell.emojiLabel.text = recent.keys[recent.startIndex.advancedBy(indexPath.item)]
//Get emoji from full, hardcoded list
} else if indexPath.section == 1 {
cell.emojiLabel.text = emojiList[indexPath.item]
}
return cell
}
That way you're certain that the label is released when you scroll.
Now I have one question. Why do you add a gesture recognizer to your EmojiViews ? UICollectionView already implements this functionality with its didSelectItemAtIndexPath: delegate. Allocating extra gestureRecognizers for each loaded cell is pretty heavy.
func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath){
let cell : UICollectionViewCell = collectionView.cellForItemAtIndexPath(indexPath) as! EmojiView
// Do stuff here
}
To sum up, I would recommand to get rid of your whole init function in EmojiViews.m, use lazy loading for the labels and didSelectItemAtIndexPath: delegate for the selection events.
NB : I'm not used to swift so my code might contain a few mistakes.
Related
First let me say this seems to be a common question on SO and I've read through every post I could find from Swift to Obj-C. I tried a bunch of different things over the last 9 hrs but my problem still exists.
I have a vc (vc1) with a collectionView in it. Inside the collectionView I have a custom cell with a label and an imageView inside of it. Inside cellForItem I have a property that is also inside the the custom cell and when the property gets set from datasource[indePath.item] there is a property observer inside the cell that sets data for the label and imageView.
There is a button in vc1 that pushes on vc2, if a user chooses something from vc2 it gets passed back to vc1 via a delegate. vc2 gets popped.
The correct data always gets passed back (I checked multiple times in the debugger).
The problem is if vc1 has an existing cell in it, when the new data is added to the data source, after I reload the collectionView, the label data from that first cell now shows on the label in new cell and the data from the new cell now shows on the label from old cell.
I've tried everything from prepareToReuse to removing the label but for some reason only the cell's label data gets confused. The odd thing is sometimes the label updates correctly and other times it doesn't? The imageView ALWAYS shows the correct image and I never have any problems even when the label data is incorrect. The 2 model objects that are inside the datasource are always in their correct index position with the correct information.
What could be the problem?
vc1: UIViewController, CollectionViewDataSource & Delegate {
var datasource = [MyModel]() // has 1 item in it from viewDidLoad
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: customCell, for: indexPath) as! CustomCell
cell.priceLabel.text = ""
cell.cleanUpElements()
cell.myModel = dataSource[indexPath.item]
return cell
}
// delegate method from vc2
func appendNewDataFromVC2(myModel: MyModel) {
// show spinner
datasource.append(myModel) // now has 2 items in it
// now that new data is added I have to make a dip to fb for some additional information
firebaseRef.observeSingleEvent(of: .value, with: { (snapshot) in
if let dict = snapshot.value as? [String: Any] else { }
for myModel in self.datasource {
myModel.someValue = dict["someValue"] as? String
}
// I added the gcd timer just to give the loop time to finish just to see if it made a difference
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: {
self.datasource.sort { return $0.postDate > $1.postDate } // Even though this sorts correctly I also tried commenting this out but no difference
self.collectionView.reloadData()
// I also tried to update the layout
self.collectionView.layoutIfNeeded()
// remove spinner
}
})
}
}
CustomCell Below. This is a much more simplified version of what's inside the myModel property observer. The data that shows in the label is dependent on other data and there are a few conditionals that determine it. Adding all of that inside cellForItem would create a bunch of code that's why I didn't update the data it in there (or add it here) and choose to do it inside the cell instead. But as I said earlier, when I check the data it is always 100% correct. The property observer always works correctly.
CustomCell: UICollectionViewCell {
let imageView: UIImageView = {
let iv = UIImageView()
iv.translatesAutoresizingMaskIntoConstraints = false
return iv
}()
let priceLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
var someBoolProperty = false
var myModel: MyModel? {
didSet {
someBoolProperty = true
// I read an answer that said try to update the label on the main thread but no difference. I tried with and without the DispatchQueue
DispatchQueue.main.async { [weak self] in
self?.priceLabel.text = myModel.price!
self?.priceLabel.layoutIfNeeded() // tried with and without this
}
let url = URL(string: myModel.urlStr!)
imageView.sd_setImage(with: url!, placeholderImage: UIImage(named: "placeholder"))
// set imageView and priceLabel anchors
addSubview(imageView)
addSubview(priceLabel)
self.layoutIfNeeded() // tried with and without this
}
}
override func prepareForReuse() {
super.prepareForReuse()
// even though Apple recommends not to clean up ui elements in here, I still tried it to no success
priceLabel.text = ""
priceLabel.layoutIfNeeded() // tried with and without this
self.layoutIfNeeded() // tried with and without this
// I also tried removing the label with and without the 3 lines above
for view in self.subviews {
if view.isKind(of: UILabel.self) {
view.removeFromSuperview()
}
}
}
func cleanUpElements() {
priceLabel.text = ""
imageView.image = nil
}
}
I added 1 breakpoint for everywhere I added priceLabel.text = "" (3 total) and once the collectionView reloads the break points always get hit 6 times (3 times for the 2 objects in the datasource).The 1st time in prepareForReuse, the 2nd time in cellForItem, and the 3rd time in cleanUpElements()
Turns out I had to reset a property inside the cell. Even though the cells were being reused and the priceLabel.text was getting cleared, the property was still maintaining it's old bool value. Once I reset it via cellForItem the problem went away.
10 hrs for that, smh
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: customCell, for: indexPath) as! CustomCell
cell.someBoolProperty = false
cell.priceLabel.text = ""
cell.cleanUpElements()
cell.myModel = dataSource[indexPath.item]
return cell
}
The problem
When scrolling up and down in my (programmatically) created collectionView the cells doesn't seem to dequeued properly. This is resulting in duplication of it contents.
Video
Bug replication
Wished behaviour
I wish that the cells correctly getting dequeued and that the content does not get duplicated.
Code snippet
Code snippets are provided via Pastebin below. I had to add some code to satisfy the markdown editor here on SO...
open class CollectionDataSource<Provider: CollectionDataProviderProtocol, Cell: UICollectionViewCell>: NSObject, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout where Cell: ConfigurableCell, Provider.T == Cell.T {
https://pastebin.com/CzHYxTDD
class ProductCell: UICollectionViewCell, ConfigurableCell {
}
https://pastebin.com/9Nkr3s4B
If anything else is need, please ask in the comments.
Each time you call
func configure(_ item: ProductViewModel, at indexPath: IndexPath) {
setupProductImage(with: item.productImage)
setupStackView()
setupProductLines(with: item.productLines)
}
You create new instance productLineLabel = UILabel() inside setupProductLines() and add it to the stackView
You should change this behavior or rather clear the stack view in prepareForReuse method.
Keep in mind, that addArrangedSubview increases suviews retain count for newly added elements. If you stop your applications execution using Debug View Hierarchy button (fig 1), most likely you will see more labels than you expect in the cell.
fig 1.
The problem
Each time I call:
func configure(_ item: ProductViewModel, at indexPath: IndexPath) {
setupProductImage(with: item.productImage)
setupStackView()
setupProductLines(with: item.productLines)
}
I create a new instance of productLineLabel = UILabel()
Therefore it will be duplicated each time the configure(_ item:) is being called from the cellForRowAtIndexPath.
The solution
I used prepareForReuse recommended by llb to remove the subviews that were kind of class UIStackview (containing UILabels). I wrote the following extension to make this less tedious:
func addSubviews(with subviews: [UIView], in parent: UIView) {
subviews.forEach { parent.addSubview($0) }
}
The implementation
The only thing what was left to do was calling the custom extension function from prepareForReuse like so:
override func prepareForReuse() {
let foundStackView = subviews.filter({$0.isKind(of: UIStackView.self)})[0] as? UIStackView
guard let labels = foundStackView?.arrangedSubviews.filter({$0.isKind(of: UILabel.self)}) else { return }
foundStackView?.removeArrangedSubviews(labels, shouldRemoveFromSuperview: true)
}
Credits go to llb, see comments below! <3 Thanks.
I have a collection view in which each cell possess the ability to be interacted with by the user. Each cell has a like button and a number of likes label. When the button is pressed, the button should turn cyan, and the label (which holds the number of likes) should increment. This setup currently works. However, when I scroll through the collection view and scroll back, the button reverts to its original color (white) and the label decrements down to its original value. I have heard of an ostensibly helpful method called prepareForReuse(), but perhaps I'm not using it correctly. Here is my code:
Here is the array which holds all the cells
var objects = [LikableObject]()
Here is the class definition for these objects
class LikableObject {
var numOfLikes: Int?
var isLikedByUser: Bool?
init(numOfLikes: Int, isLikedByUser: Bool) {
self.numOfLikes = numOfLikes
self.isLikedByUser = isLikedByUser
}
}
Mind you, there is more functionality present in this object, but they are irrelevant for the purposes of this question. One important thing to be noted is that the data for each cell are grabbed using an API. I'm using Alamofire to make requests to an API that will bring back the information for the numOfLikes and isLikedByUser properties for each cell.
Here is how I load up each cell using the collection view's delegate method:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ObjectCell", for: indexPath) as! ObjectCell
cell.configureCell(
isLikedByUser: objects[indexPath.row].isLikedByUser!,
numOfLikes: objects[indexPath.row].numOfLikes!,
)
return cell
}
The ObjectCell class has these three fields:
var isLikedByUser: Bool?
#IBOutlet weak var numOfLikes: UILabel!
#IBOutlet weak var likeBtn: UIButton!
And that configureCell() method, which belongs to the cell class, is here:
public func configureCell(numOfLikes: Int, isLikedByUser: Bool) {
self.isLikedByUser = isLikedByUser
self.numOfLikes.text = String(numOfLikes)
if isLikedByUser {
self.likeBtn.setFATitleColor(color: UIColor.cyan, forState: .normal)
} else {
self.likeBtn.setFATitleColor(color: UIColor.white, forState: .normal)
}
}
And lastly, the prepareForReuse() method is here:
override func prepareForReuse() {
if isLikedByUser! {
self.likeBtn.setTitleColor(UIColor.cyan, for: .normal)
} else {
self.likeBtn.setTitleColor(UIColor.white, for: .normal)
}
}
This doesn't work. And even if it did, I still don't know a way to keep the numOfLikes label from decrementing, or if it should anyway. I'm speculating that a big part of this problem is that I'm not using the prepareForReuse() method correctly... Any help is appreciated, thank you.
prepareForReuse is not the place to modify the cell, as the name states, you "only" have to prepare it for reuse. if you changed something (for example isHidden property of a view), you just have to change them back to initial state.
What you should do though, you can implement didSet for isLikedByUser inside the cell, and apply your modifications to likeBtn in there. (this is of-course the fast solution)
Long solution: It's an anti-pattern that your cell has a property named isLikedByUser, TableViewCell is a View and in all architectures, Views should be as dumb as they can about business logic. the right way is to apply these modifications in configure-cell method which is implemented in ViewController.
If you feel you'll reuse this cell in different viewControllers a lot, at least defined it by a protocol and talk to your cell through that protocol. This way you'll have a more reusable and maintainable code.
Currently all of this is good , the only missing part is cell reusing , you have to reflect the changes in the number of likes to your model array
class ObjectCell:UICollectionViewCell {
var myObject:LikableObject!
}
In cellForRowAt
cell.myObject = objects[indexPath.row]
Now inside cell custom class you have the object reflect any change to it , sure you can use delegate / callback or any observation technique
The prepareForResuse isn't needed here.
You do need to update the model underlying the tableview. One way to verify this is with mock data that is pre-liked and see if that data displays properly.
I have a custom UICollectionViewCell that I am attempting to pass a value to from my view controller. I'm able to pass an image to the cell, but anything else comes up nil upon initialization.
Relevant code in the View Controller:
override func viewDidLoad() {
self.collectionView!.registerClass(MyCustomCell.self, forCellWithReuseIdentifier: "Cell")
}
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("Cell", forIndexPath: indexPath) as! MyCustomCell
cell.someValue = 5
cell.imageView.image = UIImage(named: "placeholder.png")
return cell
}
In the custom cell class:
var someValue: Int!
var imageView: UIImageView!
override init(frame: CGRect) {
super.init(frame: frame)
imageView = UIImageView(frame: CGRectMake(0, 0, frame.width, frame.height))
contentView.addSubview(imageView)
let someValueLabel = UILabel()
someValueLabel.frame = CGRectMake(0, 75, 200, 30)
someValueLabel.text = "Value is: \(someValue)"
self.addSubview(someValueLabel)
}
The image is successfully passed from the UICollectionView and I am able to display it, but 'someValue' is always nil.
What am I doing wrong?
The init method is called much earlier than you think it is -- within the dequeue process -- when the cell object is constructed. Part of initialization process is to attach the UIViews designed in Storyboard. So that image works because the UIImageView is already in place as a container during the Storyboard (NIB) loading process, and you're just setting its internal image property later.
You have correctly set the value of someValue for all future use, during cell rendering and event handling. So, for example, if there's an #IBAction handler that runs after the cell is displayed and tapped on, it will indeed have access to someValue. That's where your test print should go. What are you ultimately using someValue for?
FOLLOWUP
So it's a simple error; you just need to set the text value in cellForRowAtIndexPath. You don't need a copy of model data in the cell (i.e. no need to have a someValue field in your cell), either. Just configure the UI dynamically from your (properly separated) model data:
instead of:
cell.someValue = 5
You just need, e.g.:
cell.someValueLabel.text = "\(indexPath.row)" // or where ever you're getting your underlying model data from
It's a misconception to use init for any of this. The only responsibility of init for table cells is to allocate memory. A cell is a completely dynamic, temporary object, and all of its properties that reflect Application data must be set in the cellForRowAtIndexPath method. The visual rendering of the cell waits for the cellForRowAtIndexPath method to finish, so there's no timing problem.
Init method is called when the UICollectionView is instantiated. You're logging "someValue" in the init method and it's too early. Image is rendered since you're working to the ImageView directly that has already been instantiated. Try to log imageView.image in the init method, it should be nil too (or maybe not nil because the cell is reused).
You should make your job in custom variables setters and getters, where you're sure that they're not nil.
var someValue: Int!{
didSet {
print("Passed value is: \(newValue)")
}
}
You are setting the value of someValue after the cell has been initialized.
You are calling print("Passed value is: \(someValue)") during the initialization process.
Set a break point on the init method of your cell class. You should see it pass through there before you assign the value 5 to that variable.
How to redraw non-visible UICollectionViewCell's ready for when reuse occurs???
One approach I thought of was per the code in the Layout Cell prepareForReuse function, however whilst it works it non-optimal as it causes more re-drawing then required.
Background: Need to trigger drawRect for cells after an orientation change that are not current visible, but pop up to be used and haven't been redraw, so so far I can only see that prepareForReuse would be appropriate. Issue is I'm re-drawing all "reuse" cells, whereas I really only want to redraw those that initially pop up that were created during the previous orientation position of the device.
ADDITIONAL INFO: So currently I'm doing this:
In ViewController:
override func viewWillLayoutSubviews() {
// Clear cached layout attributes (to ensure new positions are calculated)
(self.cal.collectionViewLayout as! GCCalendarLayout).resetCache()
self.cal.collectionViewLayout.invalidateLayout()
// Trigger cells to redraw themselves (to get new widths etc)
for cell in self.cal?.visibleCells() as! [GCCalendarCell] {
cell.setNeedsDisplay()
}
// Not sure how to "setNeedsDisplay" on non visible cells here?
}
In Layout Cell class:
override func prepareForReuse() {
super.prepareForReuse()
// Ensure "drawRect" is called (only way I could see to handle change in orientation
self.setNeedsDisplay()
// ISSUE: It does this also for subsequent "prepareForReuse" after all
// non-visible cells have been re-used and re-drawn, so really
// not optimal
}
Example of what happens without the code in prepareForReuse above. Snapshot taken after an orientation change, and just after scrolling up a little bit:
I think I have it now here:
import UIKit
#IBDesignable class GCCalendarCell: UICollectionViewCell {
var prevBounds : CGRect?
override func layoutSubviews() {
if let prevBounds = prevBounds {
if !( (prevBounds.width == bounds.width) && (prevBounds.height == bounds.height) ) {
self.setNeedsDisplay()
}
}
}
override func drawRect(rect: CGRect) {
// Do Stuff
self.prevBounds = self.bounds
}
}
Noted this check didn't work in "prepareForReuse" as at this time the cell had not had the rotation applied. Seems to work in "layoutSubviews" however.
You can implement some kind of communication between the cells and the view controller holding the collection view ( protocol and delegate or passed block or even direct reference to the VC ). Then You can ask the view controller for rotation changes.
Its a bit messy, but if You have some kind of rotation tracking in Your view controller You can filter the setNeedsDisplay with a simple if statement.
I had similar challenged updating cells that were already displayed and off the screen. While cycling through ALLL cells may not be possible - refreshing / looping through non-visible ones is.
IF this is your use case - then read on. Pre - Warning - if you're adding this sort of code - explain why you're doing it. It's kind of anti pattern - but can help fix that bug and help ship your app albeit adding needless complexity. Don't use this in multiple spots in app.
Any collectionviewcell that's de-initialized (off the screen and being recylced) should be unsubscribed automatically.
Notification Pattern
let kUpdateButtonBarCell = NSNotification.Name("kUpdateButtonBarCell")
class Notificator {
static func fireNotification(notificationName: NSNotification.Name) {
NotificationCenter.default.post(name: notificationName, object: nil)
}
}
extension UICollectionViewCell{
func listenForBackgroundChanges(){
NotificationCenter.default.removeObserver(self, name: kUpdateButtonBarCell, object: nil)
NotificationCenter.default.addObserver(forName:kUpdateButtonBarCell, object: nil, queue: OperationQueue.main, using: { (note) in
print( " contentView: ",self.contentView)
})
}
}
override func collectionView(collectionView: UICollectionView!, cellForItemAtIndexPath indexPath: NSIndexPath!) -> UICollectionViewCell! {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("die", forIndexPath: indexPath) as UICollectionViewCell
cell.listenForBackgroundChanges()
return cell
}
// Where appropriate broadcast notification to hook into all cells past and present
Notificator.fireNotification(notificationName: kUpdateButtonBarCell)
Delegate Pattern
It's possible to simplify this.... an exercise for the reader. just do not retain the cells (use a weak link) - otherwise you'll have memory leaks.